diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index d285f01a..17ac8f30 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -39,6 +39,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#2048) Added current due and scheduled dates to the edit modal's task information. Thanks to @1activegeek for requesting this. - (#2058, #2059) Added natural-language parsing and the full date picker to date-type custom fields. Thanks to @chmac for requesting this. - (#2060) Added date-type custom fields to the task right-click menu under **Custom dates**. Thanks to @chmac for requesting this. +- (#2067) Added an **Edit task** action to the task card context menu, including inline task cards. Thanks to @DarkCellar for requesting this. +- (#2068) Added `reminders` input support to the MCP `tasknotes_create_task` and `tasknotes_update_task` tools. Thanks to @Spirit597 for requesting this. ## Fixed diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 17241192..7760f07b 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -457,6 +457,17 @@ export class TaskContextMenu { this.menu.addSeparator(); + // Edit Task + this.menu.addItem((item) => { + item.setTitle(this.t("modals.taskEdit.title")); + item.setIcon("pencil"); + item.onClick(() => { + void plugin.openTaskEditModal(task, () => { + this.options.onUpdate?.(); + }); + }); + }); + // Open Note this.menu.addItem((item) => { item.setTitle(this.t("contextMenus.task.openNote")); diff --git a/src/services/MCPService.ts b/src/services/MCPService.ts index 4c88828d..97ee297d 100644 --- a/src/services/MCPService.ts +++ b/src/services/MCPService.ts @@ -19,6 +19,7 @@ import { FilterOperator, TaskGroupKey, TaskSortKey, + Reminder, } from "../types"; import { computeActiveTimeSessions, @@ -40,6 +41,23 @@ const MCP_FILTER_OPERATOR_DESCRIPTION = [ "Filter operator. Valid operators:", MCP_FILTER_OPERATOR_VALUES.join(", "), ].join(" "); +const MCP_REMINDER_SCHEMA = z.object({ + id: z.string().describe("Unique reminder ID"), + type: z.enum(["absolute", "relative"]).describe("Reminder type"), + relatedTo: z + .enum(["scheduled", "due"]) + .optional() + .describe("Anchor date for relative reminders"), + offset: z + .string() + .optional() + .describe("ISO 8601 duration offset for relative reminders, e.g. -PT1H"), + absoluteTime: z + .string() + .optional() + .describe("Absolute reminder time as an ISO 8601 timestamp"), + description: z.string().optional().describe("Optional reminder description"), +}); function createMcpJsonReplacer(): (this: unknown, key: string, value: unknown) => unknown { const ancestors: unknown[] = []; @@ -98,15 +116,17 @@ type CreateTaskArgs = { projects?: string[]; recurrence?: string; timeEstimate?: number; + reminders?: Reminder[]; details?: string; }; type UpdateTaskArgs = TaskIdArgs & - Partial> & { + Partial> & { title?: string; recurrence?: string | null; timeEstimate?: number | null; due?: string | null; scheduled?: string | null; + reminders?: Reminder[] | null; }; type CompleteRecurringArgs = TaskIdArgs & { date?: string }; type MaterializeOccurrenceArgs = TaskIdArgs & { date: string }; @@ -299,6 +319,10 @@ export class MCPService { projects: z.array(z.string()).optional().describe("Projects"), recurrence: z.string().optional().describe("RFC 5545 recurrence rule"), timeEstimate: z.number().optional().describe("Time estimate in minutes"), + reminders: z + .array(MCP_REMINDER_SCHEMA) + .optional() + .describe("Task reminders"), details: z.string().optional().describe("Task body/description"), }, }, @@ -317,6 +341,7 @@ export class MCPService { projects: args.projects, recurrence: args.recurrence, timeEstimate: args.timeEstimate, + reminders: args.reminders, details: args.details, creationContext: "api", }; @@ -361,6 +386,11 @@ export class MCPService { .nullable() .optional() .describe("New time estimate in minutes or null to clear"), + reminders: z + .array(MCP_REMINDER_SCHEMA) + .nullable() + .optional() + .describe("New reminders array or null to clear"), details: z.string().optional().describe("New body/description"), }, }, @@ -374,6 +404,11 @@ export class MCPService { // Build updates object, filtering out undefined values const cleanUpdates: Record = {}; for (const [key, value] of Object.entries(updates)) { + if (key === "reminders" && value === null) { + cleanUpdates.reminders = undefined; + continue; + } + if (value !== undefined) { cleanUpdates[key] = value; } diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index b375ca56..642e96bc 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -343,6 +343,12 @@ function removeUnsetMappedFields( ) { delete frontmatter[fieldMapper.toUserField("blockedBy")]; } + if ( + Object.prototype.hasOwnProperty.call(updates, "reminders") && + (!Array.isArray(updates.reminders) || updates.reminders.length === 0) + ) { + delete frontmatter[fieldMapper.toUserField("reminders")]; + } } export function buildUpdatedTaskFromPlan({ diff --git a/tests/unit/issues/issue-2067-inline-task-edit-context-menu.test.ts b/tests/unit/issues/issue-2067-inline-task-edit-context-menu.test.ts new file mode 100644 index 00000000..c9d0aa38 --- /dev/null +++ b/tests/unit/issues/issue-2067-inline-task-edit-context-menu.test.ts @@ -0,0 +1,127 @@ +import { App, Menu } from "obsidian"; +import { MockObsidian } from "../../helpers/obsidian-runtime"; +import { TaskContextMenu } from "../../../src/components/TaskContextMenu"; +import { createI18nService } from "../../../src/i18n"; +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; + +type MockMenuItem = { + setTitle?: jest.Mock; + setIcon?: jest.Mock; + onClick?: jest.Mock; +}; + +type MockMenu = { + items: MockMenuItem[]; +}; + +const menuMock = Menu as unknown as jest.Mock; + +function createTask(): TaskInfo { + return { + id: "Tasks/edit-from-menu.md", + path: "Tasks/edit-from-menu.md", + title: "Edit from menu", + status: "open", + priority: "normal", + archived: false, + tags: [], + contexts: [], + projects: [], + } as TaskInfo; +} + +function createPlugin(): TaskNotesPlugin { + return { + app: new App(), + i18n: createI18nService(), + settings: { + customStatuses: [], + customPriorities: [], + calendarViewSettings: { + enableTimeblocking: false, + }, + useFrontmatterMarkdownLinks: true, + }, + statusManager: { + getAllStatuses: jest.fn(() => []), + getNonCompletionStatuses: jest.fn(() => []), + isCompletedStatus: jest.fn(() => false), + }, + priorityManager: { + getAllPriorities: jest.fn(() => []), + getPrioritiesByWeight: jest.fn(() => []), + }, + taskService: { + toggleRecurringTaskSkipped: jest.fn(), + updateBlockingRelationships: jest.fn(), + deleteTask: jest.fn(), + }, + cacheManager: { + getAllContexts: jest.fn(() => []), + getAllTasks: jest.fn(() => []), + getTaskInfo: jest.fn(), + }, + updateTaskProperty: jest.fn(), + toggleRecurringTaskComplete: jest.fn(), + getActiveTimeSession: jest.fn(() => null), + stopTimeTracking: jest.fn(), + startTimeTracking: jest.fn(), + openDueDateModal: jest.fn(), + openScheduledDateModal: jest.fn(), + openTimeEntryEditor: jest.fn(), + toggleTaskArchive: jest.fn(), + openTaskEditModal: jest.fn(), + openTaskCreationModal: jest.fn(), + } as unknown as TaskNotesPlugin; +} + +function getTopLevelMenu(): MockMenu { + return menuMock.mock.results[0].value as MockMenu; +} + +function findTopLevelTitle(menu: MockMenu, title: string): MockMenuItem | undefined { + return menu.items.find((item) => + item.setTitle?.mock.calls.some(([value]) => value === title) + ); +} + +describe("Issue #2067: inline task context menu edit action", () => { + beforeEach(() => { + MockObsidian.reset(); + menuMock.mockClear(); + }); + + afterEach(() => { + MockObsidian.reset(); + menuMock.mockClear(); + }); + + it("adds an Edit task action that opens the edit modal and refreshes after save", () => { + const task = createTask(); + const plugin = createPlugin(); + const onUpdate = jest.fn(); + + new TaskContextMenu({ + task, + plugin, + targetDate: new Date("2026-06-23T12:00:00"), + onUpdate, + }); + + const editItem = findTopLevelTitle(getTopLevelMenu(), "Edit task"); + + expect(editItem).toBeDefined(); + expect(editItem?.setIcon).toHaveBeenCalledWith("pencil"); + + const editHandler = editItem?.onClick?.mock.calls[0][0]; + editHandler(); + + expect(plugin.openTaskEditModal).toHaveBeenCalledWith(task, expect.any(Function)); + + const editCallback = (plugin.openTaskEditModal as jest.Mock).mock.calls[0][1]; + editCallback({ ...task, title: "Edited from menu" }); + + expect(onUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/issues/issue-2068-mcp-reminders.test.ts b/tests/unit/issues/issue-2068-mcp-reminders.test.ts new file mode 100644 index 00000000..0b1df874 --- /dev/null +++ b/tests/unit/issues/issue-2068-mcp-reminders.test.ts @@ -0,0 +1,234 @@ +import { z } from "zod"; + +jest.mock("@modelcontextprotocol/sdk/server/streamableHttp.js", () => ({ + StreamableHTTPServerTransport: jest.fn(), +})); + +import { MCPService } from "../../../src/services/MCPService"; +import { applyTaskUpdateFrontmatterChange } from "../../../src/services/task-service/taskUpdatePlanning"; +import type { Reminder, TaskInfo } from "../../../src/types"; + +type ToolConfig = { + description: string; + inputSchema: z.ZodRawShape; +}; + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +type ToolCallback = (args: Record) => Promise; + +type CapturedTool = { + name: string; + config: ToolConfig; + callback: ToolCallback; +}; + +type CapturableMCPService = { + getToolRegistrar(server: unknown): ( + name: string, + config: ToolConfig, + callback: ToolCallback + ) => void; + registerTaskTools(server: unknown): void; +}; + +function createTask(overrides: Partial = {}): TaskInfo { + return { + title: "MCP reminders", + status: "open", + priority: "normal", + path: "Tasks/mcp-reminders.md", + archived: false, + ...overrides, + }; +} + +function captureTaskTools(options: { + taskService?: Partial<{ + createTask: jest.Mock; + updateTask: jest.Mock; + }>; + cacheManager?: Partial<{ + getTaskInfo: jest.Mock; + }>; + pluginSettings?: Record; +} = {}): CapturedTool[] { + const service = new MCPService( + { + settings: { + defaultTaskStatus: "open", + defaultTaskPriority: "normal", + ...(options.pluginSettings ?? {}), + }, + } as never, + options.taskService as never, + {} as never, + options.cacheManager as never, + {} as never, + {} as never, + {} as never + ); + const capturedTools: CapturedTool[] = []; + const capturableService = service as unknown as CapturableMCPService; + + capturableService.getToolRegistrar = () => (name, config, callback) => { + capturedTools.push({ name, config, callback }); + }; + capturableService.registerTaskTools({}); + + return capturedTools; +} + +function getTaskTool(name: string, tools: CapturedTool[]): CapturedTool { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`${name} was not registered`); + } + return tool; +} + +const relativeReminder: Reminder = { + id: "rem_due_1", + type: "relative", + relatedTo: "due", + offset: "-PT1H", + description: "Before due", +}; + +const absoluteReminder: Reminder = { + id: "rem_abs_1", + type: "absolute", + absoluteTime: "2026-07-03T09:00:00", + description: "Absolute reminder", +}; + +describe("Issue #2068: MCP create/update reminders input", () => { + it("accepts reminders in the tasknotes_create_task schema and passes them to task creation", async () => { + const createTask = jest.fn(async (taskData) => ({ + taskInfo: createTaskInfo(taskData), + })); + const tools = captureTaskTools({ + taskService: { createTask }, + }); + const createTool = getTaskTool("tasknotes_create_task", tools); + const schema = z.object(createTool.config.inputSchema); + + expect( + schema.safeParse({ + title: "MCP create reminders", + reminders: [relativeReminder, absoluteReminder], + }).success + ).toBe(true); + + await createTool.callback({ + title: "MCP create reminders", + reminders: [relativeReminder, absoluteReminder], + }); + + expect(createTask).toHaveBeenCalledWith( + expect.objectContaining({ + title: "MCP create reminders", + reminders: [relativeReminder, absoluteReminder], + creationContext: "api", + }) + ); + }); + + it("accepts reminders or null in the tasknotes_update_task schema", () => { + const tools = captureTaskTools(); + const updateTool = getTaskTool("tasknotes_update_task", tools); + const schema = z.object(updateTool.config.inputSchema); + + expect( + schema.safeParse({ + id: "Tasks/mcp-reminders.md", + reminders: [relativeReminder], + }).success + ).toBe(true); + + expect( + schema.safeParse({ + id: "Tasks/mcp-reminders.md", + reminders: null, + }).success + ).toBe(true); + }); + + it("passes reminder replacement and clear updates to task update", async () => { + const task = createTask({ + reminders: [absoluteReminder], + }); + const getTaskInfo = jest.fn(async () => task); + const updateTask = jest.fn(async (originalTask, updates) => ({ + ...originalTask, + ...updates, + })); + const tools = captureTaskTools({ + taskService: { updateTask }, + cacheManager: { getTaskInfo }, + }); + const updateTool = getTaskTool("tasknotes_update_task", tools); + + await updateTool.callback({ + id: task.path, + reminders: [relativeReminder], + }); + await updateTool.callback({ + id: task.path, + reminders: null, + }); + + expect(updateTask).toHaveBeenNthCalledWith(1, task, { + reminders: [relativeReminder], + }); + expect(updateTask).toHaveBeenNthCalledWith(2, task, { + reminders: undefined, + }); + }); + + it("removes the mapped reminders field when updates clear reminders", () => { + const frontmatter: Record = { + title: "MCP reminders", + reminders: [absoluteReminder], + }; + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask({ reminders: [absoluteReminder] }), + updates: { reminders: undefined }, + recurrenceUpdates: {}, + dateModified: "2026-06-23T08:30:00Z", + fieldMapper: { + mapToFrontmatter: jest.fn(() => ({ + title: "MCP reminders", + reminders: undefined, + })), + toUserField: jest.fn((field) => field), + }, + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter).not.toHaveProperty("reminders"); + }); +}); + +function createTaskInfo(taskData: Partial): TaskInfo { + return { + title: taskData.title ?? "MCP reminders", + status: taskData.status ?? "open", + priority: taskData.priority ?? "normal", + path: taskData.path || "Tasks/mcp-reminders.md", + archived: taskData.archived ?? false, + ...taskData, + }; +}