From 133608ac936553b0e718b3f56cd50747eb8a7cc3 Mon Sep 17 00:00:00 2001 From: Christen Date: Thu, 6 Aug 2026 16:01:16 +0200 Subject: [PATCH 1/6] fix recurring gcal duplicate on completion advance --- docs/releases/unreleased.md | 4 ++ .../task-service/taskUpdatePlanning.ts | 71 +++++++++++++++++-- ...sue-1696-gcal-recurring-reschedule.test.ts | 30 ++++++++ .../unit/services/taskUpdatePlanning.test.ts | 62 ++++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index ca144a1ba..2dc7355b2 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -76,6 +76,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- Completing or skipping an instance of a scheduled-anchored recurring task from + the edit modal's completions calendar no longer creates a duplicate Google + Calendar event at the next occurrence date. The scheduled date advancing as + part of completion is no longer mistaken for manually moving that occurrence. - (#2182) Checklist progress on task cards now excludes cancelled markdown checklist items such as `[-]` from the completed/total count. Thanks to @ctrl-q for reporting this. diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 324608744..0d86d6d58 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -6,6 +6,7 @@ import { import { applyGoogleCalendarRecurringExceptionCleanup, applyGoogleCalendarRecurringExceptionForScheduledChange, + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction, } from "./googleCalendarRecurringExceptions"; import { applyPropertyTaskIdentifier, @@ -90,6 +91,54 @@ function stripTimeEntryDuration(entry: TimeEntry): TimeEntry { return sanitizedEntry; } +function getStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +/** + * When completing or skipping a recurring instance also advances `scheduled` to the + * next occurrence, that's the series cursor rolling forward - not a manual reschedule + * of a single occurrence. Returns the instance date that was newly marked complete or + * skipped, if any, so the caller can resolve (rather than create) a Google Calendar + * "moved occurrence" exception. Without this distinction, sync would create a detached + * event for the next occurrence in addition to the recurring series event already + * covering that date. + */ +function getNewlyRecordedInstanceDate( + originalTask: TaskInfo, + updates: TaskUpdateInput +): string | undefined { + const originalCompleted = new Set(getStringArray(originalTask.complete_instances)); + const originalSkipped = new Set(getStringArray(originalTask.skipped_instances)); + + let latest: string | undefined; + const consider = (dateStr: string) => { + if (!latest || dateStr > latest) { + latest = dateStr; + } + }; + + if (Object.prototype.hasOwnProperty.call(updates, "complete_instances")) { + for (const dateStr of getStringArray(updates.complete_instances)) { + if (!originalCompleted.has(dateStr)) { + consider(dateStr); + } + } + } + + if (Object.prototype.hasOwnProperty.call(updates, "skipped_instances")) { + for (const dateStr of getStringArray(updates.skipped_instances)) { + if (!originalSkipped.has(dateStr)) { + consider(dateStr); + } + } + } + + return latest; +} + export function normalizeTaskUpdateDetails(updates: TaskUpdateInput): string | null { if (!Object.prototype.hasOwnProperty.call(updates, "details")) { return null; @@ -167,13 +216,25 @@ export function buildTaskUpdateRecurrenceUpdates({ if (Object.prototype.hasOwnProperty.call(updates, "scheduled")) { const nextTask: TaskInfo = { ...originalTask, ...updates, ...recurrenceUpdates }; - applyGoogleCalendarRecurringExceptionForScheduledChange( - originalTask, - updates.scheduled, - nextTask - ); + const completionActionDate = getNewlyRecordedInstanceDate(originalTask, updates); + + if (completionActionDate) { + resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( + originalTask, + completionActionDate, + nextTask + ); + } else { + applyGoogleCalendarRecurringExceptionForScheduledChange( + originalTask, + updates.scheduled, + nextTask + ); + } + recurrenceUpdates.googleCalendarExceptionOriginalScheduled = nextTask.googleCalendarExceptionOriginalScheduled; + recurrenceUpdates.googleCalendarMovedOriginalDates = nextTask.googleCalendarMovedOriginalDates; } const nextTask: TaskInfo = { ...originalTask, ...updates, ...recurrenceUpdates }; diff --git a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts index 3ebc83c33..2a5cba2c8 100644 --- a/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts +++ b/tests/unit/issues/issue-1696-gcal-recurring-reschedule.test.ts @@ -284,4 +284,34 @@ describe("Issue #1696: Google Calendar recurring reschedule sync", () => { expect(frontmatter.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBeUndefined(); }); + + it("does not flag a moved occurrence when the completions calendar advances scheduled (regression)", async () => { + const frontmatter: Record = {}; + const plugin = createGoogleSyncPlugin(frontmatter); + const taskService = new TaskService(plugin); + const task = { + path: "TaskNotes/Tasks/Collect medication.md", + title: "Collect medication", + status: "ready", + priority: "normal", + archived: false, + scheduled: "2026-04-13", + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + } as TaskInfo; + + // Mirrors what the task edit modal's completions calendar sends: it checks off + // the current instance and advances `scheduled` to the next occurrence in the + // same update, unlike a manual drag-to-reschedule which only changes `scheduled`. + const updatedTask = await taskService.updateTask(task, { + complete_instances: ["2026-04-13"], + scheduled: "2026-05-11", + }); + + expect(updatedTask.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + expect(frontmatter.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); }); diff --git a/tests/unit/services/taskUpdatePlanning.test.ts b/tests/unit/services/taskUpdatePlanning.test.ts index 31215bfc9..a4b758bed 100644 --- a/tests/unit/services/taskUpdatePlanning.test.ts +++ b/tests/unit/services/taskUpdatePlanning.test.ts @@ -137,6 +137,68 @@ describe("taskUpdatePlanning", () => { }); }); + it("does not flag a moved-occurrence exception when scheduled advances from completing an instance", () => { + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + scheduled: "2026-04-13", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + }), + updates: { + scheduled: "2026-05-11", + complete_instances: ["2026-04-13"], + }, + maintainDueDateOffsetInRecurring: false, + }); + + expect(result.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + }); + + it("resolves a pending moved-occurrence exception when the moved instance is completed", () => { + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + scheduled: "2026-04-15", + complete_instances: [], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + googleCalendarExceptionOriginalScheduled: "2026-04-13", + }), + updates: { + scheduled: "2026-05-13", + complete_instances: ["2026-04-15"], + }, + maintainDueDateOffsetInRecurring: false, + }); + + expect(result.googleCalendarExceptionOriginalScheduled).toBeUndefined(); + expect(result.googleCalendarMovedOriginalDates).toEqual(["2026-04-13"]); + }); + + it("still flags a moved-occurrence exception for a genuine manual reschedule alongside instance edits", () => { + const result = buildTaskUpdateRecurrenceUpdates({ + originalTask: createTask({ + recurrence: "DTSTART:20260316;FREQ=WEEKLY;INTERVAL=4;BYDAY=MO", + recurrence_anchor: "scheduled", + scheduled: "2026-04-13", + complete_instances: ["2026-03-16"], + skipped_instances: [], + googleCalendarEventId: "master-event-id", + }), + updates: { + scheduled: "2026-04-14", + complete_instances: ["2026-03-16"], + }, + maintainDueDateOffsetInRecurring: false, + }); + + expect(result.googleCalendarExceptionOriginalScheduled).toBe("2026-04-13"); + }); + it("adds DTSTART when a scheduled recurring task moves and the rule lacks DTSTART", () => { const addDTSTARTToRecurrenceRuleFn = jest.fn(() => "DTSTART:20260521;FREQ=DAILY"); From 2f03be7afb73e51dc97d5dd24222483b2ba76dff Mon Sep 17 00:00:00 2001 From: Callum Macdonald Date: Tue, 11 Aug 2026 22:04:14 +0200 Subject: [PATCH 2/6] Claude: Add --today and --future classes. #2223 --- src/ui/taskCardProperties.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ui/taskCardProperties.ts b/src/ui/taskCardProperties.ts index 434dd5111..1d83c263a 100644 --- a/src/ui/taskCardProperties.ts +++ b/src/ui/taskCardProperties.ts @@ -851,6 +851,12 @@ function renderDueDateProperty( if (isDueOverdue) { element.classList.add("task-card__metadata-date--overdue"); } + if (isDueToday) { + element.classList.add("task-card__metadata-date--today"); + } + if (!isDueOverdue && !isDueToday) { + element.classList.add("task-card__metadata-date--future"); + } element.dataset.tnAction = "edit-date"; element.dataset.tnDateType = "due"; @@ -906,6 +912,12 @@ function renderScheduledDateProperty( if (isScheduledPast) { element.classList.add("task-card__metadata-date--past"); } + if (isScheduledToday) { + element.classList.add("task-card__metadata-date--today"); + } + if (!isScheduledPast && !isScheduledToday) { + element.classList.add("task-card__metadata-date--future"); + } element.dataset.tnAction = "edit-date"; element.dataset.tnDateType = "scheduled"; From 8cb1f81ae0decde66ed269a38e7eb7ffc3c39c52 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 15 Sep 2026 15:18:19 +1000 Subject: [PATCH 3/6] fix: reduce bundle size and enforce Obsidian Sync budget (#2336) --- .github/workflows/release.yaml | 3 +++ .github/workflows/test.yml | 28 ++++++++++++++++++++++ docs/releases/unreleased.md | 2 ++ esbuild.config.mjs | 2 ++ package.json | 1 + scripts/check-bundle-size.mjs | 20 ++++++++++++++++ scripts/check-bundle-size.test.mjs | 38 ++++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+) create mode 100644 scripts/check-bundle-size.mjs create mode 100644 scripts/check-bundle-size.test.mjs diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e54614339..02a1d46e5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -60,6 +60,9 @@ jobs: EOF npm run build # This includes CSS building via npm run build-css + - name: Check Obsidian Sync bundle size budget + run: npm run check:bundle-size + - name: Verify release notes run: | if [ ! -f "$RELEASE_NOTES_PATH" ]; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 122b38128..01b0e2f85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,34 @@ on: branches: [ main, develop, v3-maintenance ] jobs: + bundle-size: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit + timeout-minutes: 5 + + - name: Test bundle size guard + run: node --test scripts/check-bundle-size.test.mjs + + - name: Build production plugin + run: npm run build + timeout-minutes: 10 + + - name: Check Obsidian Sync bundle size budget + run: npm run check:bundle-size + test: runs-on: ubuntu-latest strategy: diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 3a310a602..163778e60 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- (#2336) Reduced the plugin bundle below Obsidian Sync Standard's 5 MB per-file limit so it can sync between devices again. Thanks to @kmalakoff for reporting and @CyberBlaed for confirming. + - (#2301) Fixed clicking the Pomodoro timer display to edit its duration. Thanks to @minnyee for reporting and identifying the cause. - (#2328) Fixed direct status edits on occurrence notes not updating the recurring parent’s completion history or creating the next occurrence when configured. Thanks to @mudnug for reporting this. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a2f1b704e..bb9fe36c0 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -96,6 +96,8 @@ const context = await esbuild.context({ treeShaking: true, outfile: "main.js", minify: prod, + // Avoid bulky Unicode escapes in translations; keep the bundle within Obsidian Sync's limit. + charset: "utf8", plugins: [markdownPlugin, ...(!prod ? [copyToVaultPlugin] : [])], }); diff --git a/package.json b/package.json index f4d215d46..1c13d4ec4 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "npm run build-css && node generate-release-notes-import.mjs && tsc -noEmit --skipLibCheck && node esbuild.config.mjs production", "dev": "npm run build-css && node generate-release-notes-import.mjs && node esbuild.config.mjs", "build:test": "npm run build && node copy-files.mjs", + "check:bundle-size": "node scripts/check-bundle-size.mjs", "copy-files": "node copy-files.mjs", "build-css": "node build-css.mjs", "e2e": "playwright test", diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100644 index 000000000..bfa2e48ae --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,20 @@ +import { statSync } from "node:fs"; + +// Use decimal MB conservatively, and require headroom below the limit. +const limit = 5_000_000; +const bundle = process.argv[2] ?? "main.js"; + +try { + const stats = statSync(bundle); + if (!stats.isFile() || stats.size === 0) { + throw new Error("expected a non-empty bundle file"); + } + console.log(`${bundle}: ${stats.size.toLocaleString("en-US")} bytes; must be below ${limit.toLocaleString("en-US")} bytes.`); + if (stats.size >= limit) { + console.error("Bundle exceeds the size budget for Obsidian Sync Standard. Reduce the production bundle before releasing."); + process.exitCode = 1; + } +} catch (error) { + console.error(`Cannot check bundle ${bundle}: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/check-bundle-size.test.mjs b/scripts/check-bundle-size.test.mjs new file mode 100644 index 000000000..dd9689685 --- /dev/null +++ b/scripts/check-bundle-size.test.mjs @@ -0,0 +1,38 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, truncateSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const script = fileURLToPath(new URL("./check-bundle-size.mjs", import.meta.url)); + +for (const [name, size, expectedStatus] of [ + ["below limit", 4_999_999, 0], + ["at limit", 5_000_000, 1], + ["above limit", 5_000_001, 1], + ["empty file", 0, 1], + ["missing file", null, 1], +]) { + test(name, () => { + const directory = mkdtempSync(join(tmpdir(), "tasknotes-bundle-size-")); + try { + const bundle = join(directory, "main.js"); + if (size !== null) { + writeFileSync(bundle, ""); + truncateSync(bundle, size); + } + // Check both the default main.js path and an explicit path. + for (const args of [[], [bundle]]) { + const result = spawnSync(process.execPath, [script, ...args], { + cwd: directory, + encoding: "utf8", + }); + assert.equal(result.status, expectedStatus, result.stdout + result.stderr); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +} From 711af578386074b14c03058e7bf12d785a41ab06 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 15 Sep 2026 18:39:41 +1000 Subject: [PATCH 4/6] fix: deduplicate date-fns and AJV in plugin bundle --- .github/workflows/test.yml | 4 +- docs/releases/unreleased.md | 2 +- package-lock.json | 183 +++++++---------------- package.json | 1 + scripts/bundle-dependencies.test.mjs | 39 +++++ src/services/MicrosoftCalendarService.ts | 4 +- 6 files changed, 101 insertions(+), 132 deletions(-) create mode 100644 scripts/bundle-dependencies.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01b0e2f85..d2cbbede0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,8 +25,8 @@ jobs: run: npm ci --prefer-offline --no-audit timeout-minutes: 5 - - name: Test bundle size guard - run: node --test scripts/check-bundle-size.test.mjs + - name: Test bundle size guard and dependency deduplication + run: node --test scripts/check-bundle-size.test.mjs scripts/bundle-dependencies.test.mjs - name: Build production plugin run: npm run build diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 163778e60..a73dad834 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,7 +34,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed -- (#2336) Reduced the plugin bundle below Obsidian Sync Standard's 5 MB per-file limit so it can sync between devices again. Thanks to @kmalakoff for reporting and @CyberBlaed for confirming. +- (#2336) Reduced the plugin bundle below Obsidian Sync Standard's 5 MB per-file limit so it can sync between devices again, and removed duplicate bundled dependencies to leave more room for future updates. Thanks to @kmalakoff for reporting and @CyberBlaed for confirming. - (#2301) Fixed clicking the Pomodoro timer display to edit its duration. Thanks to @minnyee for reporting and identifying the cause. diff --git a/package-lock.json b/package-lock.json index 8f4171a8a..4f4d234f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@fullcalendar/timegrid": "^6.1.17", "@modelcontextprotocol/sdk": "1.26.0", "@tasknotes/model": "0.3.0-rc.9", + "ajv": "8.20.0", "chrono-node": "^2.7.5", "date-fns": "^4.1.0", "ical.js": "^2.2.1", @@ -1687,6 +1688,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -1731,6 +1749,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -2789,28 +2814,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@napi-rs/canvas": { "version": "0.1.100", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", @@ -4889,16 +4892,15 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -4922,28 +4924,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7541,23 +7521,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -7568,13 +7531,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/eslint-plugin-json-schema-validator/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", @@ -7890,6 +7846,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -7921,6 +7894,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -10806,35 +10786,10 @@ "ajv": "^8.0.0" } }, - "node_modules/json-schema-migrate/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/json-schema-migrate/node_modules/json-schema-traverse": { + "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { @@ -14154,30 +14109,6 @@ "node": ">=10.0.0" } }, - "node_modules/table/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", diff --git a/package.json b/package.json index 1c13d4ec4..7f68b53ca 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@fullcalendar/timegrid": "^6.1.17", "@modelcontextprotocol/sdk": "1.26.0", "@tasknotes/model": "0.3.0-rc.9", + "ajv": "8.20.0", "chrono-node": "^2.7.5", "date-fns": "^4.1.0", "ical.js": "^2.2.1", diff --git a/scripts/bundle-dependencies.test.mjs b/scripts/bundle-dependencies.test.mjs new file mode 100644 index 000000000..f834fee90 --- /dev/null +++ b/scripts/bundle-dependencies.test.mjs @@ -0,0 +1,39 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; + +const require = createRequire(import.meta.url); + +test("MCP and ajv-formats share the pinned runtime AJV", () => { + const sdkRequire = createRequire(require.resolve("@modelcontextprotocol/sdk/server/index.js")); + const formatsRequire = createRequire(sdkRequire.resolve("ajv-formats")); + assert.equal(sdkRequire.resolve("ajv"), require.resolve("ajv")); + assert.equal(formatsRequire.resolve("ajv"), require.resolve("ajv")); + assert.equal(require("ajv/package.json").version, "8.20.0"); +}); + +test("MCP JSON Schema validation retains format and format-limit checks", () => { + const validate = new AjvJsonSchemaValidator().getValidator({ + type: "object", + required: ["email", "date"], + additionalProperties: false, + properties: { + email: { type: "string", format: "email" }, + date: { type: "string", format: "date", formatMinimum: "2026-01-01" }, + }, + }); + const valid = { email: "test@example.com", date: "2026-09-15" }; + assert.equal(validate(valid).valid, true); + for (const input of [ + { ...valid, email: "not-an-email" }, + { ...valid, date: "not-a-date" }, + { ...valid, date: "2025-12-31" }, + { ...valid, extra: true }, + { email: valid.email }, + ]) { + const result = validate(input); + assert.equal(result.valid, false); + assert.ok(result.errorMessage); + } +}); diff --git a/src/services/MicrosoftCalendarService.ts b/src/services/MicrosoftCalendarService.ts index ff194af10..9af0eb017 100644 --- a/src/services/MicrosoftCalendarService.ts +++ b/src/services/MicrosoftCalendarService.ts @@ -1,4 +1,5 @@ import { requestUrl } from "obsidian"; +import { format, parseISO } from "date-fns"; import TaskNotesPlugin from "../main"; import { OAuthService } from "./OAuthService"; import { ICSEvent } from "../types"; @@ -580,9 +581,6 @@ export class MicrosoftCalendarService extends CalendarProvider { start = msEvent.start.dateTime.split("T")[0]; end = msEvent.end.dateTime.split("T")[0]; } else { - // eslint-disable-next-line @typescript-eslint/no-require-imports -- date-fns is lazy-loaded inside Microsoft all-day event conversion. - const { format, parseISO } = require("date-fns"); - const startIso = this.ensureUtcDateTime(msEvent.start.dateTime, msEvent.start.timeZone); const endIso = this.ensureUtcDateTime(msEvent.end.dateTime, msEvent.end.timeZone); From acb129a3589db50dca7b5e22aa6803cdde3678b1 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 15 Sep 2026 09:08:09 +1000 Subject: [PATCH 5/6] fix: preserve literal current-note folder values (#2335) --- docs/releases/unreleased.md | 2 ++ src/services/TaskService.ts | 15 ++++++++--- .../task-service/TaskCreationService.ts | 27 +++---------------- src/utils/folderTemplateProcessor.ts | 11 ++++++++ tests/unit/services/TaskService.test.ts | 17 ++++++++++++ .../utils/folderTemplateProcessor.test.ts | 12 +++++++++ 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index a73dad834..9e9755e3b 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -36,6 +36,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2336) Reduced the plugin bundle below Obsidian Sync Standard's 5 MB per-file limit so it can sync between devices again, and removed duplicate bundled dependencies to leave more room for future updates. Thanks to @kmalakoff for reporting and @CyberBlaed for confirming. +- (#2335) Fixed date-like text in the current note’s folder or title being replaced when choosing a task folder with `{{currentNotePath}}` or `{{currentNoteTitle}}`. Date tokens written in the folder template still work. Thanks to @hikatamika for reporting. + - (#2301) Fixed clicking the Pomodoro timer display to edit its duration. Thanks to @minnyee for reporting and identifying the cause. - (#2328) Fixed direct status edits on occurrence notes not updating the recurring parent’s completion history or creating the next occurrence when configured. Thanks to @mudnug for reporting this. diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index aedd1a5ff..c328274f2 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -36,7 +36,7 @@ import { getDatePart, } from "../utils/dateUtils"; import { updateToNextScheduledOccurrence } from "../core/recurrence"; -import { processFolderTemplate, TaskTemplateData } from "../utils/folderTemplateProcessor"; +import { processFolderTemplate, TaskTemplateData, FolderTemplateOptions } from "../utils/folderTemplateProcessor"; import TaskNotesPlugin from "../main"; import type { InterpolationValues, TranslationKey } from "../i18n"; @@ -108,8 +108,13 @@ export class TaskService { applyTaskCreationDefaults: (taskData) => Promise.resolve(applyTaskCreationDefaultsToData(taskData, this.plugin.settings)), applyTemplate: (taskData) => this.applyTemplate(taskData), - processFolderTemplate: (folderTemplate, taskData, date) => - this.processFolderTemplate(folderTemplate, taskData, date), + processFolderTemplate: (folderTemplate, taskData, date) => { + const currentFile = this.plugin.app.workspace.getActiveFile(); + return this.processFolderTemplate(folderTemplate, taskData, date, { + path: currentFile?.parent?.path || "", + title: currentFile?.basename || "", + }); + }, sanitizeTitleForFilename: sanitizeTaskTitleForFilename, sanitizeTitleForStorage: sanitizeTaskTitleForStorage, }); @@ -252,7 +257,8 @@ export class TaskService { private processFolderTemplate( folderTemplate: string, taskData?: TaskCreationData, - date: Date = new Date() + date: Date = new Date(), + currentNote?: FolderTemplateOptions["currentNote"] ): string { // Convert TaskCreationData to TaskTemplateData const templateData: TaskTemplateData | undefined = taskData @@ -271,6 +277,7 @@ export class TaskService { return processFolderTemplate(folderTemplate, { date, taskData: templateData, + currentNote, extractProjectBasename: (project) => this.extractProjectBasename(project), extractProjectFilePath: (project) => this.extractProjectFilePath(project), }); diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 2fe83ca37..4aca45d95 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -419,43 +419,22 @@ export class TaskCreationService { } } - private resolveCurrentNoteFolderVariables(folderTemplate: string): string { - if ( - !folderTemplate.includes("{{currentNotePath}}") && - !folderTemplate.includes("{{currentNoteTitle}}") - ) { - return folderTemplate; - } - - const currentFile = this.deps.runtime.app.workspace.getActiveFile(); - return folderTemplate - .replace(/\{\{currentNotePath\}\}/g, currentFile?.parent?.path || "") - .replace(/\{\{currentNoteTitle\}\}/g, currentFile?.basename || ""); - } - private async resolveTargetFolder(taskData: TaskCreationData): Promise { const { runtime } = this.deps; - let folder = ""; - if ( taskData.creationContext === "inline-conversion" || taskData.creationContext === "modal-inline-creation" ) { const inlineFolder = runtime.settings.inlineTaskConvertFolder || ""; if (inlineFolder.trim()) { - folder = this.resolveCurrentNoteFolderVariables(inlineFolder); - return this.deps.processFolderTemplate(folder, taskData); + return this.deps.processFolderTemplate(inlineFolder, taskData); } - const tasksFolder = this.resolveCurrentNoteFolderVariables( - runtime.settings.tasksFolder || "" - ); + const tasksFolder = runtime.settings.tasksFolder || ""; return this.deps.processFolderTemplate(tasksFolder, taskData); } - const tasksFolder = this.resolveCurrentNoteFolderVariables( - runtime.settings.tasksFolder || "" - ); + const tasksFolder = runtime.settings.tasksFolder || ""; return this.deps.processFolderTemplate(tasksFolder, taskData); } } diff --git a/src/utils/folderTemplateProcessor.ts b/src/utils/folderTemplateProcessor.ts index bb3089b8f..d2092cab2 100644 --- a/src/utils/folderTemplateProcessor.ts +++ b/src/utils/folderTemplateProcessor.ts @@ -29,6 +29,9 @@ export interface ICSTemplateData { * Options for processing folder templates */ export interface FolderTemplateOptions { + /** Literal current-note values, expanded after date and task variables. */ + currentNote?: { path: string; title: string }; + /** * Date to use for date-based template variables * @default new Date() @@ -495,6 +498,14 @@ export function processFolderTemplate( const nanoId = Date.now().toString() + Math.random().toString(36).substring(2, 7); processedPath = processedPath.replace(/\{\{nano\}\}/g, nanoId); + if (options.currentNote) { + const currentNote = options.currentNote; + processedPath = processedPath.replace( + /\{\{currentNote(Path|Title)\}\}/g, + (_match, field: string) => field === "Path" ? currentNote.path : currentNote.title + ); + } + return shouldNormalizeRelativeSegments ? normalizeRelativeFolderPath(processedPath) : processedPath; diff --git a/tests/unit/services/TaskService.test.ts b/tests/unit/services/TaskService.test.ts index f5c7fea90..4ecb27e24 100644 --- a/tests/unit/services/TaskService.test.ts +++ b/tests/unit/services/TaskService.test.ts @@ -227,6 +227,23 @@ describe('TaskService', () => { expect(mockPlugin.app.vault.read).toHaveBeenCalledWith(mockTemplateFile); }); + it.each([undefined, 'inline-conversion', 'modal-inline-creation'] as const)( + 'preserves date-like current note paths for %s creation (#2335)', async (creationContext) => { + mockPlugin.settings.tasksFolder = '{{currentNotePath}}/YYYY/{{currentNoteTitle}}'; + mockPlugin.settings.inlineTaskConvertFolder = mockPlugin.settings.tasksFolder; + const file = new TFile('AMM-01/AMM-01.md'); + file.parent = { path: 'AMM-01' } as any; + mockPlugin.app.workspace.getActiveFile.mockReturnValue(file); + + await taskService.createTask({ title: 'Literal Path', creationContext }); + + expect(mockPlugin.app.vault.create).toHaveBeenCalledWith( + `AMM-01/${new Date().getFullYear()}/AMM-01/literal-path.md`, + expect.any(String) + ); + } + ); + it('should handle inline conversion context with currentNotePath variable', async () => { mockPlugin.settings.inlineTaskConvertFolder = 'Tasks/{{currentNotePath}}'; diff --git a/tests/unit/utils/folderTemplateProcessor.test.ts b/tests/unit/utils/folderTemplateProcessor.test.ts index 8ac2e0f5d..f5b67e0b1 100644 --- a/tests/unit/utils/folderTemplateProcessor.test.ts +++ b/tests/unit/utils/folderTemplateProcessor.test.ts @@ -1,6 +1,18 @@ import { processFolderTemplate, TaskTemplateData, ICSTemplateData } from '../../../src/utils/folderTemplateProcessor'; describe('processFolderTemplate', () => { + it('treats current-note values as literal data while expanding template dates (#2335)', () => { + expect(processFolderTemplate('{{currentNotePath}}/YYYY/MM/{{currentNoteTitle}}', { + date: new Date(2026, 8, 14), + currentNote: { path: 'AMM-01/YYYY', title: '$&-{{month}}-{{currentNotePath}}-DD' } + })).toBe('AMM-01/YYYY/2026/09/$&-{{month}}-{{currentNotePath}}-DD'); + }); + + it('normalizes relative templates only after inserting literal note paths (#2335)', () => { + expect(processFolderTemplate('{{currentNotePath}}/../Tasks/{{currentNoteTitle}}', { + currentNote: { path: 'AMM-01/Meetings', title: 'AMM-01' } + })).toBe('AMM-01/Tasks/AMM-01'); + }); const testDate = new Date('2025-10-05T14:30:00'); describe('date variables', () => { From 4208928a841bf65d9d6bce6b89e3f4ed253d6c35 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 15 Sep 2026 18:59:46 +1000 Subject: [PATCH 6/6] fix: filter ICS declines by owner and clarify MCP setup --- docs/HTTP_API.md | 35 ++++++++++ docs/features/calendar-integration.md | 2 + docs/releases/unreleased.md | 6 ++ i18n.manifest.json | 2 +- i18n.state.json | 32 ++++----- src/i18n/resources/de.ts | 2 +- src/i18n/resources/en.ts | 2 +- src/i18n/resources/es.ts | 2 +- src/i18n/resources/fr.ts | 2 +- src/i18n/resources/ja.ts | 2 +- src/i18n/resources/ko.ts | 2 +- src/i18n/resources/pt.ts | 2 +- src/i18n/resources/ru.ts | 2 +- src/i18n/resources/zh.ts | 2 +- src/services/ICSSubscriptionService.ts | 28 +++----- src/services/TaskService.ts | 3 + src/utils/icsAttendeeFiltering.ts | 30 +++++++++ .../issue-1542-ics-declined-events.test.ts | 4 +- .../issue-2313-ics-guest-declines.test.ts | 67 +++++++++++++++++++ 19 files changed, 183 insertions(+), 44 deletions(-) create mode 100644 src/utils/icsAttendeeFiltering.ts create mode 100644 tests/unit/issues/issue-2313-ics-guest-declines.test.ts diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 57b1e908b..0b2147e63 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -35,6 +35,41 @@ Example: curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/health ``` +## Connecting Claude Desktop with MCP + +TaskNotes 4.13.0 and later require a **TaskNotes API token**, not a Claude/Anthropic API key. The local MCP endpoint uses bearer authentication, not OAuth registration. + +1. In **Settings → TaskNotes → Integrations**, enable **HTTP API** and **MCP server**. +2. To generate a TaskNotes token, leave **API authentication token** empty and restart Obsidian. An existing TaskNotes token can be reused; do not clear it unless you intend to replace it for all clients. +3. Reopen Integrations and copy the generated token. Keep it private. +4. In Claude Desktop, open **Settings → Developer → Edit Config** and add the following entry, preserving any other MCP servers: + +```json +{ + "mcpServers": { + "Tasknotes": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://127.0.0.1:8080/mcp", + "--header", + "Authorization:${AUTH_HEADER}" + ], + "env": { + "AUTH_HEADER": "Bearer YOUR_TASKNOTES_API_TOKEN" + } + } + } +} +``` + +Replace `YOUR_TASKNOTES_API_TOKEN` with the token from TaskNotes, keeping the `Bearer ` prefix. Change `8080` if you configured a different port. This configuration requires Node.js/npm so Claude can run `npx`. + +5. Fully quit and reopen Claude Desktop. Keep Obsidian running while using the tools. + +If Claude reports **Server Disconnected**, or `mcp-remote` fails in `registerClient`/OAuth registration, first check that the bearer header contains the current TaskNotes token. A missing or rejected token can cause the client to attempt OAuth registration, which this endpoint does not provide. After changing the token in TaskNotes, update every client and restart Claude. Do not disable authentication as a workaround. + ## Response Format Success: diff --git a/docs/features/calendar-integration.md b/docs/features/calendar-integration.md index 4f78507c6..0089403f4 100644 --- a/docs/features/calendar-integration.md +++ b/docs/features/calendar-integration.md @@ -72,6 +72,8 @@ TaskNotes can subscribe to external calendar feeds using the iCalendar (ICS) for Add and manage ICS subscriptions from `Settings -> TaskNotes -> Integrations` (Calendar Subscriptions section). +Cancelled events are hidden. A guest declining an invitation does not hide the meeting. When a feed's `X-WR-CALNAME` is an email address (as in many personal Google Calendar feeds), TaskNotes matches that address to the owner's `ATTENDEE` response and hides meetings they declined. Recurring exceptions can override the series response. If the feed does not identify the owner, TaskNotes keeps events visible rather than guessing whose response applies. This does not use the subscription's display name. Refresh the subscription after updating to restore previously hidden meetings. + For details on creating notes and tasks from calendar events, see [ICS Integration](ics-integration.md). ## Time Blocking diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index f727c5cea..95ca64e18 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -38,6 +38,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- (#2313) Fixed ICS meetings disappearing when another guest declined. Declined meetings are hidden only when the feed identifies the calendar owner's response; otherwise they remain visible. Refresh subscriptions after updating. See [Calendar Integration](https://tasknotes.dev/features/calendar-integration/#ics-calendar-subscriptions). Thanks to @benschifman for reporting and providing the reproduction. + - (#2203) Fixed completing or skipping a recurring instance in the edit modal creating a duplicate Google Calendar event when the scheduled date advances. Manual rescheduling still creates the appropriate moved occurrence. Thanks to @christenbc for the fix. - (#2336) Reduced the plugin bundle below Obsidian Sync Standard's 5 MB per-file limit so it can sync between devices again, and removed duplicate bundled dependencies to leave more room for future updates. Thanks to @kmalakoff for reporting and @CyberBlaed for confirming. @@ -47,3 +49,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2301) Fixed clicking the Pomodoro timer display to edit its duration. Thanks to @minnyee for reporting and identifying the cause. - (#2328) Fixed direct status edits on occurrence notes not updating the recurring parent’s completion history or creating the next occurrence when configured. Thanks to @mudnug for reporting this. + +## Changed + +- (#2329) Clarified how to generate a TaskNotes API token and configure Claude Desktop's MCP bearer authentication, including restart and token replacement instructions. See [HTTP API](https://tasknotes.dev/HTTP_API/#connecting-claude-desktop-with-mcp). Thanks to @kmaustral for reporting the setup confusion and confirming the solution. diff --git a/i18n.manifest.json b/i18n.manifest.json index 2d8b2a796..f08222d65 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1215,7 +1215,7 @@ "settings.integrations.httpApi.port.description": "b6428bfa6964ba7f4e87759cca444726cb674f67", "settings.integrations.httpApi.port.placeholder": "7507d41ecbd162a0d6dfdaaa9988a91184351735", "settings.integrations.httpApi.authToken.name": "1ba4dadc61225e43b73eaa19ae9c57856129ff60", - "settings.integrations.httpApi.authToken.description": "4645950dc83ea5b5601e58223affbd795b962da4", + "settings.integrations.httpApi.authToken.description": "fdd1030eecc5290a4e2bca46f8809277659f93ce", "settings.integrations.httpApi.authToken.placeholder": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", "settings.integrations.httpApi.mcp.enable.name": "9eba0b2f5b88c4347b23d9fa6c942fc6aa0482f8", "settings.integrations.httpApi.mcp.enable.description": "8d7793dc5646fed97cd0505769894e69227f59c4", diff --git a/i18n.state.json b/i18n.state.json index dcd6c7640..3f1d0989e 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -4865,8 +4865,8 @@ "translation": "e50713ea8f3f9cd33d13e62b2f9446506bba0ea3" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "74c173787c7db629c78eeb2bcfa56a8c053a5f05" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "2c95c0d616e6b1c3399ada537e8920463914cf19" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -13827,8 +13827,8 @@ "translation": "6fe94ef62939d4193d6fa157862ab7d5e221928d" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "c2bae8e5fb8112db9f4082e1a7b152ba60a3c6c2" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "ee31554b1740438b50b7f8fe3174a737e016d5d1" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -22789,8 +22789,8 @@ "translation": "7825b9593525f7e3ea8068d78e10507085cd174e" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "d132b21f1a09c216bce09c229759f17501d04d79" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "410d1907b9f34d269e5b6ab32b782a108f136a87" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -31751,8 +31751,8 @@ "translation": "4547f427f7ad30077971b37d14a3d18392ffb209" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "f0956cfefe9d470fa5920f96fc6a9c9fe8f34071" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "d3d849ef07f7e8aea516c17497077013941a62a5" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -40713,8 +40713,8 @@ "translation": "e3762218f782e767ec7710ad5ae8af573a53e91d" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "e102ad3dbcaec0d0599191fe1fd57751220daed8" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "08ed9f463b8d4df5ab14624b5c48fe82e0c4539b" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -49675,8 +49675,8 @@ "translation": "a290148f2f26d6fc99f0343e99f8bc1d17cf550d" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "2bf0b0029283a34afa81afbb40323a6af90e768b" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "6faf7ce9863aa5fa8ab04c0cab6df1693ccb314b" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -58637,8 +58637,8 @@ "translation": "9a20576cbad2401545a21cce3a90dc01cb77f9cf" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "dcccbcc0d0fc6924eba4be25691241a587c005fb" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "90cf59746a592a0558b5859ce552c699ff1774f1" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", @@ -67599,8 +67599,8 @@ "translation": "a154370a00ca79783bcc05897a84f78a62897467" }, "settings.integrations.httpApi.authToken.description": { - "source": "4645950dc83ea5b5601e58223affbd795b962da4", - "translation": "0cfe0078a28e054b5c0bf3b551e7543415979f8e" + "source": "fdd1030eecc5290a4e2bca46f8809277659f93ce", + "translation": "6b7c4ad219534e1db8691afcc8f47904933f6d9a" }, "settings.integrations.httpApi.authToken.placeholder": { "source": "dfd8e9adf613d1b2a46dfac755c50174ccecef4c", diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index f778ead0c..a126bc616 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1978,7 +1978,7 @@ export const de: TranslationTree = { }, authToken: { name: "API-Authentifizierungstoken", - description: "Für die API-Authentifizierung erforderlich. Ist das Feld leer, wird beim Serverstart ein Token generiert.", + description: "TaskNotes-Token, nicht der API-Schlüssel Ihres KI-Anbieters. Zum Erstellen HTTP-API aktivieren, dieses Feld leer lassen und Obsidian neu starten. Anschließend das erzeugte Token in die Bearer-Authentifizierungseinstellungen des Clients kopieren. Bei jeder Token-Änderung die Clients aktualisieren.", placeholder: "dein-geheimes-token" }, mcp: { diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 81e0f654b..0a7bfbd93 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2118,7 +2118,7 @@ export const en: TranslationTree = { }, authToken: { name: "API authentication token", - description: "Required for API authentication. If empty, a token is generated when the server starts.", + description: "TaskNotes token, not your AI provider's API key. To generate one, enable HTTP API, leave this field empty, and restart Obsidian. Then copy the generated token into your client's bearer authentication settings. Update clients whenever this token changes.", placeholder: "your-secret-token", }, mcp: { diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 85cf6f313..68bbe7ffc 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -1978,7 +1978,7 @@ export const es: TranslationTree = { }, authToken: { name: "Token de autenticación API", - description: "Obligatorio para la autenticación de la API. Si está vacío, se genera un token al iniciar el servidor.", + description: "Token de TaskNotes, no la clave API de tu proveedor de IA. Para generar uno, activa la API HTTP, deja este campo vacío y reinicia Obsidian. Después copia el token generado en la configuración de autenticación Bearer del cliente. Actualiza los clientes cada vez que cambie este token.", placeholder: "tu-token-secreto" }, mcp: { diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index e9c3c7aa0..7d6a1d975 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -1978,7 +1978,7 @@ export const fr: TranslationTree = { }, authToken: { name: "Jeton d'authentification API", - description: "Requis pour l'authentification API. Si le champ est vide, un jeton est généré au démarrage du serveur.", + description: "Jeton TaskNotes, et non la clé API de votre fournisseur d’IA. Pour en générer un, activez l’API HTTP, laissez ce champ vide et redémarrez Obsidian. Copiez ensuite le jeton généré dans les paramètres d’authentification Bearer du client. Mettez les clients à jour chaque fois que ce jeton change.", placeholder: "votre-jeton-secret" }, mcp: { diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index 8edb62c93..c7f7174f3 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -1978,7 +1978,7 @@ export const ja: TranslationTree = { }, authToken: { name: "API認証トークン", - description: "API認証に必要です。空欄の場合、サーバー起動時にトークンが生成されます。", + description: "TaskNotesのトークンです。AIプロバイダーのAPIキーではありません。生成するにはHTTP APIを有効にし、この欄を空にしてObsidianを再起動してください。生成されたトークンをクライアントのBearer認証設定にコピーします。トークンを変更したら、各クライアントの設定も更新してください。", placeholder: "あなたのシークレットトークン" }, mcp: { diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index 27829cc9d..6d3c785c9 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -1962,7 +1962,7 @@ export const ko: TranslationTree = { }, authToken: { name: "API 인증 토큰", - description: "API 인증에 필요합니다. 비워두면 서버 시작 시 토큰이 생성됩니다.", + description: "TaskNotes 토큰이며 AI 서비스 제공업체의 API 키가 아닙니다. 생성하려면 HTTP API를 활성화하고 이 필드를 비운 뒤 Obsidian을 다시 시작하세요. 생성된 토큰을 클라이언트의 Bearer 인증 설정에 복사하세요. 토큰이 변경될 때마다 클라이언트 설정도 업데이트하세요.", placeholder: "비밀-토큰" }, mcp: { diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 338670a41..066dc724a 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1980,7 +1980,7 @@ export const pt: TranslationTree = { }, authToken: { name: "Token de autenticação da API", - description: "Obrigatório para autenticação da API. Se estiver vazio, um token será gerado ao iniciar o servidor.", + description: "Token do TaskNotes, não a chave de API do seu provedor de IA. Para gerar um, ative a API HTTP, deixe este campo vazio e reinicie o Obsidian. Depois copie o token gerado para as configurações de autenticação Bearer do cliente. Atualize os clientes sempre que este token mudar.", placeholder: "seu-token-secreto" }, mcp: { diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 1ef15c2fa..cbc1ec403 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1978,7 +1978,7 @@ export const ru: TranslationTree = { }, authToken: { name: "Токен аутентификации API", - description: "Необходим для аутентификации API. Если поле пустое, токен создаётся при запуске сервера.", + description: "Токен TaskNotes, а не API-ключ вашего поставщика ИИ. Чтобы создать токен, включите HTTP API, оставьте это поле пустым и перезапустите Obsidian. Затем скопируйте созданный токен в настройки Bearer-аутентификации клиента. Обновляйте настройки клиентов при каждом изменении токена.", placeholder: "ваш-секретный-токен" }, mcp: { diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index ff63d7512..36b3b4c1f 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -1978,7 +1978,7 @@ export const zh: TranslationTree = { }, authToken: { name: "API认证令牌", - description: "API认证必需的令牌。如果留空,将在服务器启动时生成令牌。", + description: "这是TaskNotes令牌,不是AI服务提供商的API密钥。要生成令牌,请启用HTTP API,将此字段留空,然后重启Obsidian。随后将生成的令牌复制到客户端的Bearer认证设置中。每次更改令牌后,都需要更新客户端设置。", placeholder: "你的秘密令牌" }, mcp: { diff --git a/src/services/ICSSubscriptionService.ts b/src/services/ICSSubscriptionService.ts index 77e9b44fa..37cde5182 100644 --- a/src/services/ICSSubscriptionService.ts +++ b/src/services/ICSSubscriptionService.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- ICS parsing normalizes optional event fields before use. */ import { requestUrl, TFile } from "obsidian"; import ICAL from "ical.js"; +import { getICSCalendarOwnerEmail, getICSOwnerDeclined } from "../utils/icsAttendeeFiltering"; import { ICSSubscription, ICSEvent, ICSCache } from "../types"; import { EventEmitter } from "../utils/EventEmitter"; import TaskNotesPlugin from "../main"; @@ -504,6 +505,7 @@ export class ICSSubscriptionService extends EventEmitter { // Register VTIMEZONE components before processing events. registerCalendarVTimezones(comp); + const ownerEmail = getICSCalendarOwnerEmail(comp); const vevents = comp.getAllSubcomponents("vevent"); const events: ICSEvent[] = []; @@ -547,23 +549,11 @@ export class ICSSubscriptionService extends EventEmitter { return; } - // Skip events the user has declined. - // In a personal calendar's ICS feed the owner's ATTENDEE - // entry carries their own PARTSTAT, so if any attendee is - // marked DECLINED the event was almost certainly declined - // by the calendar owner. - const attendees = vevent.getAllProperties("attendee"); - if (attendees && attendees.length > 0) { - const hasDeclined = attendees.some((a) => { - const partstat = a.getParameter("partstat"); - return ( - typeof partstat === "string" && - partstat.toUpperCase() === "DECLINED" - ); - }); - if (hasDeclined) { - return; - } + // Another guest declining is not evidence that the owner declined. + // Keep events visible when the feed does not identify the owner. + const ownerDeclined = getICSOwnerDeclined(vevent, ownerEmail) ?? false; + if (ownerDeclined && !event.isRecurring()) { + return; } // Extract basic properties @@ -669,6 +659,9 @@ export class ICSSubscriptionService extends EventEmitter { if (typeof modifiedStatus === "string" && modifiedStatus.toUpperCase() === "CANCELLED") { continue; } + if (getICSOwnerDeclined(modifiedEvent.component, ownerEmail) ?? ownerDeclined) { + continue; + } // Use the modified event instead const modifiedStart = modifiedEvent.startDate; const modifiedEnd = modifiedEvent.endDate; @@ -705,6 +698,7 @@ export class ICSSubscriptionService extends EventEmitter { visibleInstanceCount++; } } else { + if (ownerDeclined) continue; // Use the original recurring event instance. // The iterator emits ICAL.Time values that share the // startDate's TZID, so pass startTzidRaw for fallback. diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index c328274f2..ce4c93352 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -109,6 +109,9 @@ export class TaskService { Promise.resolve(applyTaskCreationDefaultsToData(taskData, this.plugin.settings)), applyTemplate: (taskData) => this.applyTemplate(taskData), processFolderTemplate: (folderTemplate, taskData, date) => { + if (!/\{\{currentNote(?:Path|Title)\}\}/.test(folderTemplate)) { + return this.processFolderTemplate(folderTemplate, taskData, date); + } const currentFile = this.plugin.app.workspace.getActiveFile(); return this.processFolderTemplate(folderTemplate, taskData, date, { path: currentFile?.parent?.path || "", diff --git a/src/utils/icsAttendeeFiltering.ts b/src/utils/icsAttendeeFiltering.ts new file mode 100644 index 000000000..34129d46d --- /dev/null +++ b/src/utils/icsAttendeeFiltering.ts @@ -0,0 +1,30 @@ +import ICAL from "ical.js"; + +function normalizeEmail(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const email = value.trim().replace(/^mailto:/i, "").toLowerCase(); + return /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email) ? email : undefined; +} + +/** Personal Google ICS feeds commonly use the owner's email as X-WR-CALNAME. + * A display name is not an identity: never guess from a guest or organizer. + */ +export function getICSCalendarOwnerEmail(calendar: ICAL.Component): string | undefined { + return normalizeEmail(calendar.getFirstPropertyValue("x-wr-calname")); +} + +/** Undefined means the feed/event does not identify the owner's response. + * Recurrence overrides may inherit a known response from their master. + */ +export function getICSOwnerDeclined( + event: ICAL.Component, + ownerEmail: string | undefined +): boolean | undefined { + if (!ownerEmail) return undefined; + const owner = event.getAllProperties("attendee").find( + (attendee) => normalizeEmail(attendee.getFirstValue()) === ownerEmail + ); + if (!owner) return undefined; + const status = owner.getParameter("partstat"); + return typeof status === "string" && status.toUpperCase() === "DECLINED"; +} diff --git a/tests/unit/issues/issue-1542-ics-declined-events.test.ts b/tests/unit/issues/issue-1542-ics-declined-events.test.ts index ac33065df..3a44f6ce0 100644 --- a/tests/unit/issues/issue-1542-ics-declined-events.test.ts +++ b/tests/unit/issues/issue-1542-ics-declined-events.test.ts @@ -1,4 +1,5 @@ import { ICSSubscriptionService } from '../../../src/services/ICSSubscriptionService'; +jest.mock('ical.js', () => jest.requireActual('../../../node_modules/ical.js/dist/ical.es5.cjs')); jest.mock('obsidian', () => ({ Notice: jest.fn(), @@ -32,6 +33,7 @@ describe('Issue #1542 - Declined events showing up in calendar view', () => { 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Test//Test//EN', + 'X-WR-CALNAME:me@example.com', ...vevents, 'END:VCALENDAR' ].join('\r\n'); @@ -71,7 +73,7 @@ describe('Issue #1542 - Declined events showing up in calendar view', () => { expect(events).toHaveLength(0); }); - it('should filter out events where an attendee has PARTSTAT=DECLINED', () => { + it('should filter out events where the identified owner has PARTSTAT=DECLINED', () => { const ics = buildICS( ...makeEvent('declined-1', [ 'ORGANIZER;CN=Boss:mailto:boss@example.com', diff --git a/tests/unit/issues/issue-2313-ics-guest-declines.test.ts b/tests/unit/issues/issue-2313-ics-guest-declines.test.ts new file mode 100644 index 000000000..20dc39504 --- /dev/null +++ b/tests/unit/issues/issue-2313-ics-guest-declines.test.ts @@ -0,0 +1,67 @@ +import { ICSSubscriptionService } from '../../../src/services/ICSSubscriptionService'; +import type { ICSEvent } from '../../../src/types'; + +jest.mock('obsidian', () => ({ Notice: jest.fn(), requestUrl: jest.fn(), TFile: jest.fn() })); +jest.mock('ical.js', () => jest.requireActual('../../../node_modules/ical.js/dist/ical.es5.cjs')); + +const service = new ICSSubscriptionService({} as any); +const parse = (events: string[], name?: string) => (service as unknown as { + parseICS(data: string, subscriptionId: string): ICSEvent[]; +}).parseICS([ + 'BEGIN:VCALENDAR', 'VERSION:2.0', + ...(name ? [`X-WR-CALNAME:${name}`] : []), + ...events, 'END:VCALENDAR', +].join('\r\n'), 'sub'); + +const guest = 'ATTENDEE;PARTSTAT=DECLINED;X-RESPONSE-COMMENT="Sorry\\; I have a conflict: all day":mailto:guest@example.com'; +const owner = (status: string) => `ATTENDEE;PARTSTAT=${status}:mailto:OWNER@example.com`; +const event = (...properties: string[]) => [ + 'BEGIN:VEVENT', 'UID:meeting', 'DTSTART:20260914T140000Z', 'DTEND:20260914T180000Z', + 'SUMMARY:Meeting', 'STATUS:CONFIRMED', ...properties, 'END:VEVENT', +]; + +describe('Issue #2313 - only the calendar owner can decline a meeting for this feed', () => { + beforeEach(() => jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-14T00:00:00Z'))); + afterEach(() => jest.restoreAllMocks()); + + it.each(['ACCEPTED', 'TENTATIVE', 'NEEDS-ACTION'])( + 'keeps owner-%s meetings despite a guest decline and quoted delimiters', status => { + expect(parse(event(guest, owner(status)), 'owner@example.com')).toHaveLength(1); + } + ); + + it.each(['DECLINED', 'declined'])('hides the identified owner-%s meeting', status => { + expect(parse(event(guest, owner(status)), 'MAILTO:owner@example.com')).toEqual([]); + }); + + it.each([undefined, 'Team Calendar', 'Someone '])( + 'does not infer the owner from guests when the calendar name is %s', name => { + expect(parse(event(guest, owner('DECLINED')), name)).toHaveLength(1); + } + ); + + it('keeps meetings organized by the owner with no owner attendee', () => { + expect(parse(event('ORGANIZER:mailto:owner@example.com', guest), 'owner@example.com')).toHaveLength(1); + }); + + it('does not use attendee CN as an owner identity', () => { + expect(parse(event('ATTENDEE;CN=owner@example.com;PARTSTAT=DECLINED:mailto:guest@example.com'), 'owner@example.com')).toHaveLength(1); + }); + + it('still hides cancelled events regardless of attendee response', () => { + expect(parse(event(guest, owner('ACCEPTED')).map(line => line === 'STATUS:CONFIRMED' ? 'STATUS:CANCELLED' : line), 'owner@example.com')).toEqual([]); + }); + + it.each([ + ['ACCEPTED', 'DECLINED', ['2026-09-21T14:00:00.000Z']], + ['DECLINED', 'ACCEPTED', ['2026-09-14T14:00:00.000Z']], + ['ACCEPTED', undefined, ['2026-09-14T14:00:00.000Z', '2026-09-21T14:00:00.000Z']], + ['DECLINED', undefined, []], + ] as const)('uses owner responses on recurrence overrides (%s -> %s)', (masterStatus, overrideStatus, expected) => { + const events = parse([ + ...event('RRULE:FREQ=WEEKLY;COUNT=2', owner(masterStatus), guest), + ...event('RECURRENCE-ID:20260914T140000Z', guest, ...(overrideStatus ? [owner(overrideStatus)] : [])), + ], 'owner@example.com'); + expect(events.map(e => e.start)).toEqual(expected); + }); +});