add task menu edit action and mcp reminders

This commit is contained in:
callumalpass 2026-06-23 18:45:15 +10:00
parent 8d75e39ef7
commit 5a8eb76fb1
6 changed files with 416 additions and 1 deletions

View file

@ -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

View file

@ -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"));

View file

@ -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<Omit<CreateTaskArgs, "title" | "timeEstimate">> & {
Partial<Omit<CreateTaskArgs, "title" | "timeEstimate" | "reminders">> & {
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<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (key === "reminders" && value === null) {
cleanUpdates.reminders = undefined;
continue;
}
if (value !== undefined) {
cleanUpdates[key] = value;
}

View file

@ -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({

View file

@ -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);
});
});

View file

@ -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<string, unknown>) => Promise<ToolResult>;
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> = {}): 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<string, unknown>;
} = {}): 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<string, unknown> = {
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>): 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,
};
}