mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
add task menu edit action and mcp reminders
This commit is contained in:
parent
8d75e39ef7
commit
5a8eb76fb1
6 changed files with 416 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
234
tests/unit/issues/issue-2068-mcp-reminders.test.ts
Normal file
234
tests/unit/issues/issue-2068-mcp-reminders.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue