omnifocus-mcp-enhanced 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,7 @@ Want to see where the project is heading next? See the [roadmap](docs/roadmap/20
44
44
 
45
45
  ## 🆕 Latest Release
46
46
 
47
+ - **v1.9.0** - Added 3 productivity tools: `append_to_note` (append text to a task/project note without overwriting), `count_tasks` (fast "how many" aggregate queries with a status breakdown, using the same filters as `filter_tasks`), and `duplicate_task` (clone a task with or without its subtasks, optionally renamed).
47
48
  - **v1.8.0** - Added full **Folder Management** support: `add_folder`, `edit_folder`, `remove_folder`, `list_folders`, and `get_folder`. Create nested folder hierarchies, rename/move folders (with cycle protection), and inspect folder contents (child projects + subfolders). Note: removing a folder permanently deletes all projects and tasks it contains.
48
49
  - **v1.7.0** - Added OmniFocus 4.7+ repeat rule support via `set_repetition_rule` (ICS rule strings, schedule type, anchor date, catch-up, end date, repetition count), mutually exclusive tag support via `exclusiveTags` on add/edit tools, and improved planned-date editing tests.
49
50
  - **v1.6.10** - Fixed Inbox task completion via `edit_item`, fixed AppleScript special-character handling for apostrophes/backslashes, fixed JSON result escaping for special characters, and clarified `batch_add_items` / `mcporter` usage with working examples.
@@ -521,8 +522,13 @@ read_task_attachment {
521
522
  21. **list_folders** - 🆕 **NEW**: List all folders with IDs, parents, status, and project counts
522
523
  22. **get_folder** - 🆕 **NEW**: Get a single folder with its child projects and subfolders
523
524
 
525
+ ### ⚡ Productivity Tools (NEW)
526
+ 23. **append_to_note** - 🆕 **NEW**: Append text to a task/project note without overwriting
527
+ 24. **count_tasks** - 🆕 **NEW**: Fast "how many" queries with a status breakdown (same filters as filter_tasks)
528
+ 25. **duplicate_task** - 🆕 **NEW**: Clone a task with/without subtasks, optionally renamed
529
+
524
530
  ### 📊 Analytics & Tracking
525
- 23. **get_today_completed_tasks** - View today's completed tasks
531
+ 26. **get_today_completed_tasks** - View today's completed tasks
526
532
 
527
533
  Batch move feature roadmap (future): [docs/roadmap/2026-02-25-batch-move-tasks-plan.md](docs/roadmap/2026-02-25-batch-move-tasks-plan.md)
528
534
 
@@ -655,6 +661,35 @@ edit_folder {"name": "Key Clients", "newParentFolderName": ""}
655
661
  remove_folder {"name": "Old Archive"}
656
662
  ```
657
663
 
664
+ ### ⚡ Productivity Tools
665
+
666
+ ```bash
667
+ # Append a progress note without overwriting the existing note
668
+ append_to_note {
669
+ "itemType": "task",
670
+ "name": "Write report",
671
+ "text": "Drafted section 1 today"
672
+ }
673
+
674
+ # Fast count: how many flagged tasks are still actionable?
675
+ count_tasks {
676
+ "flagged": true,
677
+ "taskStatus": ["Available", "Next", "DueSoon", "Overdue"]
678
+ }
679
+
680
+ # How many tasks remain in a project (by status breakdown)
681
+ count_tasks {"projectFilter": "Website Redesign"}
682
+
683
+ # Duplicate a task template with its subtasks
684
+ duplicate_task {
685
+ "name": "Weekly Review Checklist",
686
+ "newName": "Weekly Review - 2026-03-02"
687
+ }
688
+
689
+ # Duplicate without subtasks
690
+ duplicate_task {"taskId": "abc123", "includeSubtasks": false}
691
+ ```
692
+
658
693
  ## 🔧 Configuration
659
694
 
660
695
  ### Claude Code
package/dist/server.js CHANGED
@@ -31,10 +31,14 @@ import * as editFolderTool from './tools/definitions/editFolder.js';
31
31
  import * as removeFolderTool from './tools/definitions/removeFolder.js';
32
32
  import * as listFoldersTool from './tools/definitions/listFolders.js';
33
33
  import * as getFolderTool from './tools/definitions/getFolder.js';
34
+ // Import productivity tools
35
+ import * as appendToNoteTool from './tools/definitions/appendToNote.js';
36
+ import * as countTasksTool from './tools/definitions/countTasks.js';
37
+ import * as duplicateTaskTool from './tools/definitions/duplicateTask.js';
34
38
  // Create an MCP server
35
39
  const server = new McpServer({
36
40
  name: "OmniFocus MCP",
37
- version: "1.8.0"
41
+ version: "1.9.0"
38
42
  });
39
43
  // Register tools
40
44
  server.tool("dump_database", "Gets the current state of your OmniFocus database", dumpDatabaseTool.schema.shape, dumpDatabaseTool.handler);
@@ -66,6 +70,10 @@ server.tool("edit_folder", "Rename a folder or move it under a different parent
66
70
  server.tool("remove_folder", "Remove a folder from OmniFocus. WARNING: this also permanently deletes all projects and tasks contained in the folder.", removeFolderTool.schema.shape, removeFolderTool.handler);
67
71
  server.tool("list_folders", "List all OmniFocus folders with IDs, parent relationships, status, and project counts without loading tasks.", listFoldersTool.schema.shape, listFoldersTool.handler);
68
72
  server.tool("get_folder", "Get a single OmniFocus folder by ID or name, including its child projects and subfolders.", getFolderTool.schema.shape, getFolderTool.handler);
73
+ // Productivity tools
74
+ server.tool("append_to_note", "Append text to a task or project note without overwriting the existing note. Useful for logging progress or adding context.", appendToNoteTool.schema.shape, appendToNoteTool.handler);
75
+ server.tool("count_tasks", "Count tasks matching filters without returning the full list. Fast 'how many' queries that return a total plus a breakdown by status. Uses the same filters as filter_tasks.", countTasksTool.schema.shape, countTasksTool.handler);
76
+ server.tool("duplicate_task", "Duplicate an existing task, optionally with its subtasks, and optionally with a new name. Useful for template-based workflows.", duplicateTaskTool.schema.shape, duplicateTaskTool.handler);
69
77
  // Start the MCP server
70
78
  const transport = new StdioServerTransport();
71
79
  // Use await with server.connect to ensure proper connection
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { appendToNote } from '../primitives/appendToNote.js';
3
+ export const schema = z.object({
4
+ id: z.string().optional().describe('The ID of the task or project'),
5
+ name: z.string().optional().describe('The name of the task or project (as fallback if ID not provided)'),
6
+ itemType: z.enum(['task', 'project']).describe("Type of item whose note to append to ('task' or 'project')"),
7
+ text: z.string().describe('The text to append to the existing note'),
8
+ separator: z.string().optional().describe('Separator inserted between the existing note and the new text (default: a newline). Pass an empty string to append with no separator.')
9
+ });
10
+ export async function handler(args, _extra) {
11
+ try {
12
+ if (!args.id && !args.name) {
13
+ return {
14
+ content: [{ type: 'text', text: 'Either id or name must be provided to append to a note.' }],
15
+ isError: true
16
+ };
17
+ }
18
+ const result = await appendToNote(args);
19
+ if (result.success) {
20
+ const itemTypeLabel = args.itemType === 'task' ? 'Task' : 'Project';
21
+ return {
22
+ content: [{
23
+ type: 'text',
24
+ text: `✅ Appended text to ${itemTypeLabel} "${result.name}" note.\n\nid: ${result.id}`
25
+ }]
26
+ };
27
+ }
28
+ return {
29
+ content: [{ type: 'text', text: `Failed to append to note: ${result.error}` }],
30
+ isError: true
31
+ };
32
+ }
33
+ catch (err) {
34
+ const error = err;
35
+ console.error(`Tool execution error: ${error.message}`);
36
+ return {
37
+ content: [{ type: 'text', text: `Error appending to note: ${error.message}` }],
38
+ isError: true
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { countTasks } from '../primitives/countTasks.js';
3
+ export const schema = z.object({
4
+ taskStatus: z.array(z.string()).optional().describe('Filter by task status: Available, Next, Blocked, DueSoon, Overdue, Completed, Dropped'),
5
+ perspective: z.enum(['inbox', 'flagged', 'all']).optional().describe("Scope: 'inbox', 'flagged', or 'all' (default: all)"),
6
+ projectFilter: z.string().optional().describe('Only count tasks in projects whose name contains this text'),
7
+ tagFilter: z.union([z.string(), z.array(z.string())]).optional().describe('Only count tasks with these tags'),
8
+ exactTagMatch: z.boolean().optional().describe('Require exact tag name match (default: false)'),
9
+ flagged: z.boolean().optional().describe('Only count flagged (true) or unflagged (false) tasks'),
10
+ searchText: z.string().optional().describe('Only count tasks whose name or note contains this text'),
11
+ dueToday: z.boolean().optional().describe('Only count tasks due today'),
12
+ dueThisWeek: z.boolean().optional().describe('Only count tasks due this week'),
13
+ overdue: z.boolean().optional().describe('Only count overdue tasks'),
14
+ completedToday: z.boolean().optional().describe('Only count tasks completed today'),
15
+ completedThisWeek: z.boolean().optional().describe('Only count tasks completed this week'),
16
+ plannedToday: z.boolean().optional().describe('Only count tasks planned for today'),
17
+ plannedThisWeek: z.boolean().optional().describe('Only count tasks planned for this week')
18
+ });
19
+ export async function handler(args, _extra) {
20
+ try {
21
+ const result = await countTasks(args);
22
+ const statusEntries = Object.entries(result.byStatus).sort((a, b) => b[1] - a[1]);
23
+ const breakdown = statusEntries.length > 0
24
+ ? statusEntries.map(([status, count]) => `- ${status}: ${count}`).join('\n')
25
+ : '- (no matching tasks)';
26
+ return {
27
+ content: [{
28
+ type: 'text',
29
+ text: `# Task Count\n\n**Total: ${result.total}**\n\nBy status:\n${breakdown}`
30
+ }]
31
+ };
32
+ }
33
+ catch (err) {
34
+ const error = err;
35
+ console.error(`Tool execution error: ${error.message}`);
36
+ return {
37
+ content: [{ type: 'text', text: `Error counting tasks: ${error.message}` }],
38
+ isError: true
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import { duplicateTask } from '../primitives/duplicateTask.js';
3
+ export const schema = z.object({
4
+ taskId: z.string().optional().describe('The ID of the task to duplicate'),
5
+ taskName: z.string().optional().describe('The name of the task to duplicate (as fallback if ID not provided)'),
6
+ newName: z.string().optional().describe('Optional new name for the duplicated task (keeps the original name if omitted)'),
7
+ includeSubtasks: z.boolean().optional().describe('Whether to include the task\'s subtasks in the copy (default: true)')
8
+ });
9
+ export async function handler(args, _extra) {
10
+ try {
11
+ if (!args.taskId && !args.taskName) {
12
+ return {
13
+ content: [{ type: 'text', text: 'Either taskId or taskName must be provided to duplicate a task.' }],
14
+ isError: true
15
+ };
16
+ }
17
+ const result = await duplicateTask(args);
18
+ if (result.success) {
19
+ const subtaskText = (result.childrenCount && result.childrenCount > 0)
20
+ ? ` with ${result.childrenCount} subtask(s)`
21
+ : '';
22
+ return {
23
+ content: [{
24
+ type: 'text',
25
+ text: `✅ Duplicated task as "${result.name}"${subtaskText}.\n\nid: ${result.newTaskId}`
26
+ }]
27
+ };
28
+ }
29
+ return {
30
+ content: [{ type: 'text', text: `Failed to duplicate task: ${result.error}` }],
31
+ isError: true
32
+ };
33
+ }
34
+ catch (err) {
35
+ const error = err;
36
+ console.error(`Tool execution error: ${error.message}`);
37
+ return {
38
+ content: [{ type: 'text', text: `Error duplicating task: ${error.message}` }],
39
+ isError: true
40
+ };
41
+ }
42
+ }
@@ -0,0 +1,122 @@
1
+ import { executeAppleScript } from '../../utils/scriptExecution.js';
2
+ import { buildAppleScriptJsonHelpers } from '../../utils/appleScriptJson.js';
3
+ import { escapeAppleScriptString } from '../../utils/appleScriptString.js';
4
+ /**
5
+ * Generate pure AppleScript that appends text to an item's note without overwriting.
6
+ */
7
+ export function generateAppleScript(params) {
8
+ const id = params.id ? escapeAppleScriptString(params.id) : '';
9
+ const name = params.name ? escapeAppleScriptString(params.name) : '';
10
+ const itemType = params.itemType;
11
+ const singularTypeLabel = itemType === 'task' ? 'task' : 'project';
12
+ const listName = itemType === 'task' ? 'flattened tasks' : 'flattened projects';
13
+ const appendText = escapeAppleScriptString(params.text);
14
+ // Separator handling:
15
+ // - Default (undefined): use AppleScript's `linefeed` constant for a real newline.
16
+ // - Explicit string: embed it as an escaped literal (empty string means no separator).
17
+ const useDefaultNewline = params.separator === undefined;
18
+ const separatorExpr = useDefaultNewline
19
+ ? 'linefeed'
20
+ : `"${escapeAppleScriptString(params.separator)}"`;
21
+ const jsonHelpers = buildAppleScriptJsonHelpers();
22
+ if (!id && !name) {
23
+ return `return "{\\\"success\\\":false,\\\"error\\\":\\\"Either id or name must be provided\\\"}"`;
24
+ }
25
+ if (!params.text) {
26
+ return `return "{\\\"success\\\":false,\\\"error\\\":\\\"text must be provided\\\"}"`;
27
+ }
28
+ let script = `
29
+ ${jsonHelpers}
30
+ try
31
+ tell application "OmniFocus"
32
+ tell front document
33
+ -- Find the item
34
+ set foundItem to missing value
35
+ `;
36
+ if (id) {
37
+ script += `
38
+ -- Try to find by ID first
39
+ try
40
+ set foundItem to first ${itemType === 'task' ? 'flattened task' : 'flattened project'} where id = "${id}"
41
+ end try
42
+ `;
43
+ }
44
+ if (name) {
45
+ script += `
46
+ -- Resolve by name with duplicate protection
47
+ if foundItem is missing value then
48
+ set nameMatches to (${listName} where name = "${name}")
49
+ set nameMatchCount to count of nameMatches
50
+
51
+ if nameMatchCount = 1 then
52
+ set foundItem to item 1 of nameMatches
53
+ else if nameMatchCount > 1 then
54
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Ambiguous ${singularTypeLabel} name: ${name}. Multiple matches found; please use id.") & "\\\"}"
55
+ end if
56
+ end if
57
+ `;
58
+ }
59
+ script += `
60
+ if foundItem is not missing value then
61
+ set itemName to name of foundItem
62
+ set itemId to id of foundItem as string
63
+
64
+ -- Read the existing note (may be empty)
65
+ set existingNote to note of foundItem
66
+ if existingNote is missing value then
67
+ set existingNote to ""
68
+ end if
69
+
70
+ -- Append text, using the separator only when there is existing content
71
+ if existingNote is "" then
72
+ set newNote to "${appendText}"
73
+ else
74
+ set newNote to existingNote & ${separatorExpr} & "${appendText}"
75
+ end if
76
+
77
+ set note of foundItem to newNote
78
+
79
+ return "{\\\"success\\\":true,\\\"id\\\":\\"" & my jsonEscape(itemId) & "\\",\\\"name\\\":\\"" & my jsonEscape(itemName) & "\\\"}"
80
+ else
81
+ return "{\\\"success\\\":false,\\\"error\\\":\\\"Item not found\\\"}"
82
+ end if
83
+ end tell
84
+ end tell
85
+ on error errorMessage
86
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape(errorMessage) & "\\\"}"
87
+ end try
88
+ `;
89
+ return script;
90
+ }
91
+ /**
92
+ * Append text to a task or project note in OmniFocus (non-destructive).
93
+ */
94
+ export async function appendToNote(params) {
95
+ try {
96
+ if (!params.id && !params.name) {
97
+ return { success: false, error: 'Either id or name must be provided' };
98
+ }
99
+ if (!params.text) {
100
+ return { success: false, error: 'text must be provided' };
101
+ }
102
+ const script = generateAppleScript(params);
103
+ console.error('Executing AppleScript for note append...');
104
+ const stdout = await executeAppleScript(script);
105
+ console.error('AppleScript stdout:', stdout);
106
+ try {
107
+ const result = JSON.parse(stdout);
108
+ if (!result.success) {
109
+ return { success: false, error: result.error };
110
+ }
111
+ return { success: true, id: result.id, name: result.name };
112
+ }
113
+ catch (parseError) {
114
+ console.error('Error parsing AppleScript result:', parseError);
115
+ return { success: false, error: `Failed to parse result: ${stdout}` };
116
+ }
117
+ }
118
+ catch (error) {
119
+ console.error('Error in appendToNote:', error);
120
+ return { success: false, error: error?.message || 'Unknown error in appendToNote' };
121
+ }
122
+ }
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { generateAppleScript } from './appendToNote.js';
4
+ test('appendToNote appends with a real newline (linefeed) separator by default', () => {
5
+ const script = generateAppleScript({
6
+ itemType: 'task',
7
+ name: 'My Task',
8
+ text: 'progress update'
9
+ });
10
+ // Reads existing note, appends using AppleScript's linefeed constant (real newline).
11
+ assert.match(script, /set existingNote to note of foundItem/);
12
+ assert.match(script, /set newNote to existingNote & linefeed & "progress update"/);
13
+ assert.match(script, /set note of foundItem to newNote/);
14
+ });
15
+ test('appendToNote respects an explicit empty separator', () => {
16
+ const script = generateAppleScript({
17
+ itemType: 'task',
18
+ name: 'My Task',
19
+ text: 'more',
20
+ separator: ''
21
+ });
22
+ assert.match(script, /set newNote to existingNote & "" & "more"/);
23
+ });
24
+ test('appendToNote targets flattened projects when itemType is project', () => {
25
+ const script = generateAppleScript({
26
+ itemType: 'project',
27
+ name: 'My Project',
28
+ text: 'note'
29
+ });
30
+ assert.match(script, /flattened projects where name = "My Project"/);
31
+ assert.doesNotMatch(script, /flattened tasks where name = "My Project"/);
32
+ });
33
+ test('appendToNote keeps apostrophes and doubles backslashes in text', () => {
34
+ const script = generateAppleScript({
35
+ itemType: 'task',
36
+ name: 'T',
37
+ text: "didn't work in C:\\Temp"
38
+ });
39
+ assert.match(script, /didn't work in C:\\\\Temp/);
40
+ assert.doesNotMatch(script, /\\'/);
41
+ });
42
+ test('appendToNote errors when neither id nor name provided', () => {
43
+ const script = generateAppleScript({ itemType: 'task', text: 'x' });
44
+ assert.match(script, /Either id or name must be provided/);
45
+ });
@@ -0,0 +1,34 @@
1
+ import { executeOmniFocusScript } from '../../utils/scriptExecution.js';
2
+ import { applyClientSideFilters } from './filterTasks.js';
3
+ /**
4
+ * Count tasks matching the given filters without returning the full task list.
5
+ * Reuses the same OmniJS filter script and client-side filters as filter_tasks,
6
+ * but returns only aggregate counts (fast "how many" queries, low token cost).
7
+ */
8
+ export async function countTasks(options = {}) {
9
+ const { perspective = 'all', exactTagMatch = false, sortBy = 'name', sortOrder = 'asc' } = options;
10
+ // Fetch a large set so client-side filters (tags/defer/planned) have full data.
11
+ const result = await executeOmniFocusScript('@filterTasks.js', {
12
+ ...options,
13
+ perspective,
14
+ exactTagMatch,
15
+ limit: 100000,
16
+ sortBy,
17
+ sortOrder
18
+ });
19
+ if (result && typeof result === 'object') {
20
+ const data = result;
21
+ if (data.error) {
22
+ throw new Error(data.error);
23
+ }
24
+ const tasks = Array.isArray(data.tasks) ? data.tasks : [];
25
+ const filtered = applyClientSideFilters(tasks, options);
26
+ const byStatus = {};
27
+ for (const task of filtered) {
28
+ const status = (task && task.taskStatus) ? String(task.taskStatus) : 'Unknown';
29
+ byStatus[status] = (byStatus[status] || 0) + 1;
30
+ }
31
+ return { total: filtered.length, byStatus };
32
+ }
33
+ throw new Error('Unexpected result format from OmniFocus');
34
+ }
@@ -0,0 +1,27 @@
1
+ import { executeOmniFocusScript } from '../../utils/scriptExecution.js';
2
+ export async function duplicateTask(params) {
3
+ try {
4
+ if (!params.taskId && !params.taskName) {
5
+ return { success: false, error: 'Either taskId or taskName must be provided' };
6
+ }
7
+ const result = await executeOmniFocusScript('@duplicateTask.js', {
8
+ taskId: params.taskId || null,
9
+ taskName: params.taskName || null,
10
+ newName: params.newName || null,
11
+ includeSubtasks: params.includeSubtasks !== undefined ? params.includeSubtasks : true
12
+ });
13
+ if (!result || result.success !== true) {
14
+ return { success: false, error: (result && result.error) || 'Failed to duplicate task' };
15
+ }
16
+ return {
17
+ success: true,
18
+ newTaskId: result.newTaskId,
19
+ name: result.name,
20
+ childrenCount: result.childrenCount
21
+ };
22
+ }
23
+ catch (error) {
24
+ console.error('Error in duplicateTask:', error);
25
+ return { success: false, error: error?.message || 'Unknown error in duplicateTask' };
26
+ }
27
+ }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import vm from 'node:vm';
5
+ function runDuplicateScript(context) {
6
+ const script = readFileSync(new URL('../../utils/omnifocusScripts/duplicateTask.js', import.meta.url), 'utf8');
7
+ return JSON.parse(vm.runInNewContext(script, context));
8
+ }
9
+ function makeTask(id, name, children = []) {
10
+ const task = {
11
+ id: { primaryKey: id },
12
+ name,
13
+ parent: null,
14
+ containingProject: null,
15
+ children
16
+ };
17
+ return task;
18
+ }
19
+ test('duplicateTask resolves by id and reports subtask count', () => {
20
+ const child = makeTask('c1', 'Child');
21
+ const source = makeTask('t1', 'Parent', [child]);
22
+ const clone = makeTask('t1-copy', 'Parent', [makeTask('c1-copy', 'Child')]);
23
+ const result = runDuplicateScript({
24
+ injectedArgs: { taskId: 't1', includeSubtasks: true },
25
+ flattenedTasks: [source, child],
26
+ inbox: { ending: 'inbox-end' },
27
+ duplicateTasks: (_tasks, _loc) => [clone],
28
+ deleteObject: () => { }
29
+ });
30
+ assert.equal(result.success, true);
31
+ assert.equal(result.newTaskId, 't1-copy');
32
+ assert.equal(result.childrenCount, 1);
33
+ });
34
+ test('duplicateTask renames the copy when newName provided', () => {
35
+ const source = makeTask('t1', 'Original');
36
+ const clone = makeTask('t1-copy', 'Original');
37
+ const result = runDuplicateScript({
38
+ injectedArgs: { taskId: 't1', newName: 'Renamed Copy' },
39
+ flattenedTasks: [source],
40
+ inbox: { ending: 'inbox-end' },
41
+ duplicateTasks: () => [clone],
42
+ deleteObject: () => { }
43
+ });
44
+ assert.equal(result.success, true);
45
+ assert.equal(result.name, 'Renamed Copy');
46
+ });
47
+ test('duplicateTask strips subtasks when includeSubtasks is false', () => {
48
+ const source = makeTask('t1', 'Parent');
49
+ const cloneChildren = [makeTask('cc1', 'CC1'), makeTask('cc2', 'CC2')];
50
+ const clone = makeTask('t1-copy', 'Parent', cloneChildren);
51
+ const deleted = [];
52
+ const result = runDuplicateScript({
53
+ injectedArgs: { taskId: 't1', includeSubtasks: false },
54
+ flattenedTasks: [source],
55
+ inbox: { ending: 'inbox-end' },
56
+ duplicateTasks: () => [clone],
57
+ deleteObject: (obj) => { deleted.push(obj.id.primaryKey); }
58
+ });
59
+ assert.equal(result.success, true);
60
+ assert.deepEqual(deleted, ['cc1', 'cc2']);
61
+ });
62
+ test('duplicateTask rejects ambiguous name matches', () => {
63
+ const a = makeTask('a', 'Dup');
64
+ const b = makeTask('b', 'Dup');
65
+ const result = runDuplicateScript({
66
+ injectedArgs: { taskName: 'Dup' },
67
+ flattenedTasks: [a, b],
68
+ inbox: { ending: 'inbox-end' },
69
+ duplicateTasks: () => [],
70
+ deleteObject: () => { }
71
+ });
72
+ assert.equal(result.success, false);
73
+ assert.match(result.error, /Ambiguous task name/);
74
+ });
75
+ test('duplicateTask errors when task not found', () => {
76
+ const result = runDuplicateScript({
77
+ injectedArgs: { taskId: 'missing' },
78
+ flattenedTasks: [],
79
+ inbox: { ending: 'inbox-end' },
80
+ duplicateTasks: () => [],
81
+ deleteObject: () => { }
82
+ });
83
+ assert.equal(result.success, false);
84
+ assert.match(result.error, /Task not found/);
85
+ });
@@ -0,0 +1,76 @@
1
+ // Duplicate an OmniFocus task, including its subtasks, using Omni Automation.
2
+ (() => {
3
+ try {
4
+ var args = typeof injectedArgs !== "undefined" ? injectedArgs : {};
5
+ var taskId = args.taskId || null;
6
+ var taskName = args.taskName || null;
7
+ var newName = args.newName || null;
8
+ var includeSubtasks = args.includeSubtasks !== undefined ? args.includeSubtasks : true;
9
+
10
+ if (!taskId && !taskName) {
11
+ return JSON.stringify({ success: false, error: "Either taskId or taskName must be provided" });
12
+ }
13
+
14
+ // Resolve the source task with duplicate-name protection.
15
+ var matches = flattenedTasks.filter(function (candidate) {
16
+ if (taskId) return candidate.id.primaryKey === taskId;
17
+ return candidate.name === taskName;
18
+ });
19
+
20
+ if (matches.length === 0) {
21
+ return JSON.stringify({ success: false, error: "Task not found" });
22
+ }
23
+ if (matches.length > 1 && !taskId) {
24
+ return JSON.stringify({
25
+ success: false,
26
+ error: "Ambiguous task name: " + taskName + ". Multiple matches found; please use taskId."
27
+ });
28
+ }
29
+
30
+ var source = matches[0];
31
+
32
+ // Determine the drop location (same container as the source task).
33
+ var location;
34
+ if (source.parent) {
35
+ location = source.parent.ending;
36
+ } else if (source.containingProject) {
37
+ location = source.containingProject.ending;
38
+ } else {
39
+ location = inbox.ending;
40
+ }
41
+
42
+ // duplicateTasks preserves subtasks natively.
43
+ var duplicated = duplicateTasks([source], location);
44
+ var newTask = duplicated && duplicated.length ? duplicated[0] : null;
45
+
46
+ if (!newTask) {
47
+ return JSON.stringify({ success: false, error: "Duplication failed: no task returned" });
48
+ }
49
+
50
+ // Optionally rename the duplicate.
51
+ if (newName) {
52
+ newTask.name = newName;
53
+ }
54
+
55
+ // Optionally strip subtasks if the caller does not want them.
56
+ if (!includeSubtasks && newTask.children && newTask.children.length > 0) {
57
+ // Iterate over a copy since deletion mutates the collection.
58
+ var children = newTask.children.slice();
59
+ children.forEach(function (child) {
60
+ deleteObject(child);
61
+ });
62
+ }
63
+
64
+ return JSON.stringify({
65
+ success: true,
66
+ newTaskId: newTask.id.primaryKey,
67
+ name: newTask.name,
68
+ childrenCount: newTask.children ? newTask.children.length : 0
69
+ });
70
+ } catch (error) {
71
+ return JSON.stringify({
72
+ success: false,
73
+ error: error && error.message ? error.message : String(error)
74
+ });
75
+ }
76
+ })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "omnifocus-mcp-enhanced",
3
3
  "type": "module",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "description": "🚀 NEW: Native Custom Perspective Access! Enhanced MCP server with OmniFocus custom perspective support, hierarchical task display, AI-optimized tool selection, and comprehensive task management",
6
6
  "main": "dist/server.js",
7
7
  "bin": {
package/src/server.ts CHANGED
@@ -33,11 +33,15 @@ import * as editFolderTool from './tools/definitions/editFolder.js';
33
33
  import * as removeFolderTool from './tools/definitions/removeFolder.js';
34
34
  import * as listFoldersTool from './tools/definitions/listFolders.js';
35
35
  import * as getFolderTool from './tools/definitions/getFolder.js';
36
+ // Import productivity tools
37
+ import * as appendToNoteTool from './tools/definitions/appendToNote.js';
38
+ import * as countTasksTool from './tools/definitions/countTasks.js';
39
+ import * as duplicateTaskTool from './tools/definitions/duplicateTask.js';
36
40
 
37
41
  // Create an MCP server
38
42
  const server = new McpServer({
39
43
  name: "OmniFocus MCP",
40
- version: "1.8.0"
44
+ version: "1.9.0"
41
45
  });
42
46
 
43
47
  // Register tools
@@ -221,6 +225,28 @@ server.tool(
221
225
  getFolderTool.handler
222
226
  );
223
227
 
228
+ // Productivity tools
229
+ server.tool(
230
+ "append_to_note",
231
+ "Append text to a task or project note without overwriting the existing note. Useful for logging progress or adding context.",
232
+ appendToNoteTool.schema.shape,
233
+ appendToNoteTool.handler
234
+ );
235
+
236
+ server.tool(
237
+ "count_tasks",
238
+ "Count tasks matching filters without returning the full list. Fast 'how many' queries that return a total plus a breakdown by status. Uses the same filters as filter_tasks.",
239
+ countTasksTool.schema.shape,
240
+ countTasksTool.handler
241
+ );
242
+
243
+ server.tool(
244
+ "duplicate_task",
245
+ "Duplicate an existing task, optionally with its subtasks, and optionally with a new name. Useful for template-based workflows.",
246
+ duplicateTaskTool.schema.shape,
247
+ duplicateTaskTool.handler
248
+ );
249
+
224
250
  // Start the MCP server
225
251
  const transport = new StdioServerTransport();
226
252
 
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { appendToNote, AppendToNoteParams } from '../primitives/appendToNote.js';
3
+ import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
4
+
5
+ export const schema = z.object({
6
+ id: z.string().optional().describe('The ID of the task or project'),
7
+ name: z.string().optional().describe('The name of the task or project (as fallback if ID not provided)'),
8
+ itemType: z.enum(['task', 'project']).describe("Type of item whose note to append to ('task' or 'project')"),
9
+ text: z.string().describe('The text to append to the existing note'),
10
+ separator: z.string().optional().describe('Separator inserted between the existing note and the new text (default: a newline). Pass an empty string to append with no separator.')
11
+ });
12
+
13
+ export async function handler(args: z.infer<typeof schema>, _extra: RequestHandlerExtra) {
14
+ try {
15
+ if (!args.id && !args.name) {
16
+ return {
17
+ content: [{ type: 'text' as const, text: 'Either id or name must be provided to append to a note.' }],
18
+ isError: true
19
+ };
20
+ }
21
+
22
+ const result = await appendToNote(args as AppendToNoteParams);
23
+
24
+ if (result.success) {
25
+ const itemTypeLabel = args.itemType === 'task' ? 'Task' : 'Project';
26
+ return {
27
+ content: [{
28
+ type: 'text' as const,
29
+ text: `✅ Appended text to ${itemTypeLabel} "${result.name}" note.\n\nid: ${result.id}`
30
+ }]
31
+ };
32
+ }
33
+
34
+ return {
35
+ content: [{ type: 'text' as const, text: `Failed to append to note: ${result.error}` }],
36
+ isError: true
37
+ };
38
+ } catch (err: unknown) {
39
+ const error = err as Error;
40
+ console.error(`Tool execution error: ${error.message}`);
41
+ return {
42
+ content: [{ type: 'text' as const, text: `Error appending to note: ${error.message}` }],
43
+ isError: true
44
+ };
45
+ }
46
+ }
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod';
2
+ import { countTasks, CountTasksOptions } from '../primitives/countTasks.js';
3
+ import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
4
+
5
+ export const schema = z.object({
6
+ taskStatus: z.array(z.string()).optional().describe('Filter by task status: Available, Next, Blocked, DueSoon, Overdue, Completed, Dropped'),
7
+ perspective: z.enum(['inbox', 'flagged', 'all']).optional().describe("Scope: 'inbox', 'flagged', or 'all' (default: all)"),
8
+ projectFilter: z.string().optional().describe('Only count tasks in projects whose name contains this text'),
9
+ tagFilter: z.union([z.string(), z.array(z.string())]).optional().describe('Only count tasks with these tags'),
10
+ exactTagMatch: z.boolean().optional().describe('Require exact tag name match (default: false)'),
11
+ flagged: z.boolean().optional().describe('Only count flagged (true) or unflagged (false) tasks'),
12
+ searchText: z.string().optional().describe('Only count tasks whose name or note contains this text'),
13
+ dueToday: z.boolean().optional().describe('Only count tasks due today'),
14
+ dueThisWeek: z.boolean().optional().describe('Only count tasks due this week'),
15
+ overdue: z.boolean().optional().describe('Only count overdue tasks'),
16
+ completedToday: z.boolean().optional().describe('Only count tasks completed today'),
17
+ completedThisWeek: z.boolean().optional().describe('Only count tasks completed this week'),
18
+ plannedToday: z.boolean().optional().describe('Only count tasks planned for today'),
19
+ plannedThisWeek: z.boolean().optional().describe('Only count tasks planned for this week')
20
+ });
21
+
22
+ export async function handler(args: z.infer<typeof schema>, _extra: RequestHandlerExtra) {
23
+ try {
24
+ const result = await countTasks(args as CountTasksOptions);
25
+
26
+ const statusEntries = Object.entries(result.byStatus).sort((a, b) => b[1] - a[1]);
27
+ const breakdown = statusEntries.length > 0
28
+ ? statusEntries.map(([status, count]) => `- ${status}: ${count}`).join('\n')
29
+ : '- (no matching tasks)';
30
+
31
+ return {
32
+ content: [{
33
+ type: 'text' as const,
34
+ text: `# Task Count\n\n**Total: ${result.total}**\n\nBy status:\n${breakdown}`
35
+ }]
36
+ };
37
+ } catch (err: unknown) {
38
+ const error = err as Error;
39
+ console.error(`Tool execution error: ${error.message}`);
40
+ return {
41
+ content: [{ type: 'text' as const, text: `Error counting tasks: ${error.message}` }],
42
+ isError: true
43
+ };
44
+ }
45
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import { duplicateTask, DuplicateTaskParams } from '../primitives/duplicateTask.js';
3
+ import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
4
+
5
+ export const schema = z.object({
6
+ taskId: z.string().optional().describe('The ID of the task to duplicate'),
7
+ taskName: z.string().optional().describe('The name of the task to duplicate (as fallback if ID not provided)'),
8
+ newName: z.string().optional().describe('Optional new name for the duplicated task (keeps the original name if omitted)'),
9
+ includeSubtasks: z.boolean().optional().describe('Whether to include the task\'s subtasks in the copy (default: true)')
10
+ });
11
+
12
+ export async function handler(args: z.infer<typeof schema>, _extra: RequestHandlerExtra) {
13
+ try {
14
+ if (!args.taskId && !args.taskName) {
15
+ return {
16
+ content: [{ type: 'text' as const, text: 'Either taskId or taskName must be provided to duplicate a task.' }],
17
+ isError: true
18
+ };
19
+ }
20
+
21
+ const result = await duplicateTask(args as DuplicateTaskParams);
22
+
23
+ if (result.success) {
24
+ const subtaskText = (result.childrenCount && result.childrenCount > 0)
25
+ ? ` with ${result.childrenCount} subtask(s)`
26
+ : '';
27
+ return {
28
+ content: [{
29
+ type: 'text' as const,
30
+ text: `✅ Duplicated task as "${result.name}"${subtaskText}.\n\nid: ${result.newTaskId}`
31
+ }]
32
+ };
33
+ }
34
+
35
+ return {
36
+ content: [{ type: 'text' as const, text: `Failed to duplicate task: ${result.error}` }],
37
+ isError: true
38
+ };
39
+ } catch (err: unknown) {
40
+ const error = err as Error;
41
+ console.error(`Tool execution error: ${error.message}`);
42
+ return {
43
+ content: [{ type: 'text' as const, text: `Error duplicating task: ${error.message}` }],
44
+ isError: true
45
+ };
46
+ }
47
+ }
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { generateAppleScript } from './appendToNote.js';
4
+
5
+ test('appendToNote appends with a real newline (linefeed) separator by default', () => {
6
+ const script = generateAppleScript({
7
+ itemType: 'task',
8
+ name: 'My Task',
9
+ text: 'progress update'
10
+ });
11
+
12
+ // Reads existing note, appends using AppleScript's linefeed constant (real newline).
13
+ assert.match(script, /set existingNote to note of foundItem/);
14
+ assert.match(script, /set newNote to existingNote & linefeed & "progress update"/);
15
+ assert.match(script, /set note of foundItem to newNote/);
16
+ });
17
+
18
+ test('appendToNote respects an explicit empty separator', () => {
19
+ const script = generateAppleScript({
20
+ itemType: 'task',
21
+ name: 'My Task',
22
+ text: 'more',
23
+ separator: ''
24
+ });
25
+
26
+ assert.match(script, /set newNote to existingNote & "" & "more"/);
27
+ });
28
+
29
+ test('appendToNote targets flattened projects when itemType is project', () => {
30
+ const script = generateAppleScript({
31
+ itemType: 'project',
32
+ name: 'My Project',
33
+ text: 'note'
34
+ });
35
+
36
+ assert.match(script, /flattened projects where name = "My Project"/);
37
+ assert.doesNotMatch(script, /flattened tasks where name = "My Project"/);
38
+ });
39
+
40
+ test('appendToNote keeps apostrophes and doubles backslashes in text', () => {
41
+ const script = generateAppleScript({
42
+ itemType: 'task',
43
+ name: 'T',
44
+ text: "didn't work in C:\\Temp"
45
+ });
46
+
47
+ assert.match(script, /didn't work in C:\\\\Temp/);
48
+ assert.doesNotMatch(script, /\\'/);
49
+ });
50
+
51
+ test('appendToNote errors when neither id nor name provided', () => {
52
+ const script = generateAppleScript({ itemType: 'task', text: 'x' } as any);
53
+ assert.match(script, /Either id or name must be provided/);
54
+ });
@@ -0,0 +1,150 @@
1
+ import { executeAppleScript } from '../../utils/scriptExecution.js';
2
+ import { buildAppleScriptJsonHelpers } from '../../utils/appleScriptJson.js';
3
+ import { escapeAppleScriptString } from '../../utils/appleScriptString.js';
4
+
5
+ // Interface for append-to-note parameters
6
+ export interface AppendToNoteParams {
7
+ id?: string; // ID of the task or project
8
+ name?: string; // Name of the task or project (fallback if ID not provided)
9
+ itemType: 'task' | 'project'; // Type of item whose note is being appended to
10
+ text: string; // Text to append to the note
11
+ separator?: string; // Separator between existing note and new text (default: newline)
12
+ }
13
+
14
+ /**
15
+ * Generate pure AppleScript that appends text to an item's note without overwriting.
16
+ */
17
+ export function generateAppleScript(params: AppendToNoteParams): string {
18
+ const id = params.id ? escapeAppleScriptString(params.id) : '';
19
+ const name = params.name ? escapeAppleScriptString(params.name) : '';
20
+ const itemType = params.itemType;
21
+ const singularTypeLabel = itemType === 'task' ? 'task' : 'project';
22
+ const listName = itemType === 'task' ? 'flattened tasks' : 'flattened projects';
23
+ const appendText = escapeAppleScriptString(params.text);
24
+ // Separator handling:
25
+ // - Default (undefined): use AppleScript's `linefeed` constant for a real newline.
26
+ // - Explicit string: embed it as an escaped literal (empty string means no separator).
27
+ const useDefaultNewline = params.separator === undefined;
28
+ const separatorExpr = useDefaultNewline
29
+ ? 'linefeed'
30
+ : `"${escapeAppleScriptString(params.separator as string)}"`;
31
+ const jsonHelpers = buildAppleScriptJsonHelpers();
32
+
33
+ if (!id && !name) {
34
+ return `return "{\\\"success\\\":false,\\\"error\\\":\\\"Either id or name must be provided\\\"}"`;
35
+ }
36
+
37
+ if (!params.text) {
38
+ return `return "{\\\"success\\\":false,\\\"error\\\":\\\"text must be provided\\\"}"`;
39
+ }
40
+
41
+ let script = `
42
+ ${jsonHelpers}
43
+ try
44
+ tell application "OmniFocus"
45
+ tell front document
46
+ -- Find the item
47
+ set foundItem to missing value
48
+ `;
49
+
50
+ if (id) {
51
+ script += `
52
+ -- Try to find by ID first
53
+ try
54
+ set foundItem to first ${itemType === 'task' ? 'flattened task' : 'flattened project'} where id = "${id}"
55
+ end try
56
+ `;
57
+ }
58
+
59
+ if (name) {
60
+ script += `
61
+ -- Resolve by name with duplicate protection
62
+ if foundItem is missing value then
63
+ set nameMatches to (${listName} where name = "${name}")
64
+ set nameMatchCount to count of nameMatches
65
+
66
+ if nameMatchCount = 1 then
67
+ set foundItem to item 1 of nameMatches
68
+ else if nameMatchCount > 1 then
69
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Ambiguous ${singularTypeLabel} name: ${name}. Multiple matches found; please use id.") & "\\\"}"
70
+ end if
71
+ end if
72
+ `;
73
+ }
74
+
75
+ script += `
76
+ if foundItem is not missing value then
77
+ set itemName to name of foundItem
78
+ set itemId to id of foundItem as string
79
+
80
+ -- Read the existing note (may be empty)
81
+ set existingNote to note of foundItem
82
+ if existingNote is missing value then
83
+ set existingNote to ""
84
+ end if
85
+
86
+ -- Append text, using the separator only when there is existing content
87
+ if existingNote is "" then
88
+ set newNote to "${appendText}"
89
+ else
90
+ set newNote to existingNote & ${separatorExpr} & "${appendText}"
91
+ end if
92
+
93
+ set note of foundItem to newNote
94
+
95
+ return "{\\\"success\\\":true,\\\"id\\\":\\"" & my jsonEscape(itemId) & "\\",\\\"name\\\":\\"" & my jsonEscape(itemName) & "\\\"}"
96
+ else
97
+ return "{\\\"success\\\":false,\\\"error\\\":\\\"Item not found\\\"}"
98
+ end if
99
+ end tell
100
+ end tell
101
+ on error errorMessage
102
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape(errorMessage) & "\\\"}"
103
+ end try
104
+ `;
105
+
106
+ return script;
107
+ }
108
+
109
+ /**
110
+ * Append text to a task or project note in OmniFocus (non-destructive).
111
+ */
112
+ export async function appendToNote(params: AppendToNoteParams): Promise<{
113
+ success: boolean,
114
+ id?: string,
115
+ name?: string,
116
+ error?: string
117
+ }> {
118
+ try {
119
+ if (!params.id && !params.name) {
120
+ return { success: false, error: 'Either id or name must be provided' };
121
+ }
122
+ if (!params.text) {
123
+ return { success: false, error: 'text must be provided' };
124
+ }
125
+
126
+ const script = generateAppleScript(params);
127
+
128
+ console.error('Executing AppleScript for note append...');
129
+
130
+ const stdout = await executeAppleScript(script);
131
+
132
+ console.error('AppleScript stdout:', stdout);
133
+
134
+ try {
135
+ const result = JSON.parse(stdout);
136
+
137
+ if (!result.success) {
138
+ return { success: false, error: result.error };
139
+ }
140
+
141
+ return { success: true, id: result.id, name: result.name };
142
+ } catch (parseError) {
143
+ console.error('Error parsing AppleScript result:', parseError);
144
+ return { success: false, error: `Failed to parse result: ${stdout}` };
145
+ }
146
+ } catch (error: any) {
147
+ console.error('Error in appendToNote:', error);
148
+ return { success: false, error: error?.message || 'Unknown error in appendToNote' };
149
+ }
150
+ }
@@ -0,0 +1,53 @@
1
+ import { executeOmniFocusScript } from '../../utils/scriptExecution.js';
2
+ import { FilterTasksOptions, applyClientSideFilters } from './filterTasks.js';
3
+
4
+ export interface CountTasksOptions extends FilterTasksOptions {}
5
+
6
+ export interface CountTasksResult {
7
+ total: number;
8
+ byStatus: Record<string, number>;
9
+ }
10
+
11
+ /**
12
+ * Count tasks matching the given filters without returning the full task list.
13
+ * Reuses the same OmniJS filter script and client-side filters as filter_tasks,
14
+ * but returns only aggregate counts (fast "how many" queries, low token cost).
15
+ */
16
+ export async function countTasks(options: CountTasksOptions = {}): Promise<CountTasksResult> {
17
+ const {
18
+ perspective = 'all',
19
+ exactTagMatch = false,
20
+ sortBy = 'name',
21
+ sortOrder = 'asc'
22
+ } = options;
23
+
24
+ // Fetch a large set so client-side filters (tags/defer/planned) have full data.
25
+ const result = await executeOmniFocusScript('@filterTasks.js', {
26
+ ...options,
27
+ perspective,
28
+ exactTagMatch,
29
+ limit: 100000,
30
+ sortBy,
31
+ sortOrder
32
+ });
33
+
34
+ if (result && typeof result === 'object') {
35
+ const data = result as any;
36
+ if (data.error) {
37
+ throw new Error(data.error);
38
+ }
39
+
40
+ const tasks: any[] = Array.isArray(data.tasks) ? data.tasks : [];
41
+ const filtered = applyClientSideFilters(tasks, options);
42
+
43
+ const byStatus: Record<string, number> = {};
44
+ for (const task of filtered) {
45
+ const status = (task && task.taskStatus) ? String(task.taskStatus) : 'Unknown';
46
+ byStatus[status] = (byStatus[status] || 0) + 1;
47
+ }
48
+
49
+ return { total: filtered.length, byStatus };
50
+ }
51
+
52
+ throw new Error('Unexpected result format from OmniFocus');
53
+ }
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import vm from 'node:vm';
5
+
6
+ function runDuplicateScript(context: Record<string, unknown>): any {
7
+ const script = readFileSync(
8
+ new URL('../../utils/omnifocusScripts/duplicateTask.js', import.meta.url),
9
+ 'utf8'
10
+ );
11
+ return JSON.parse(vm.runInNewContext(script, context) as string);
12
+ }
13
+
14
+ function makeTask(id: string, name: string, children: any[] = []) {
15
+ const task: any = {
16
+ id: { primaryKey: id },
17
+ name,
18
+ parent: null,
19
+ containingProject: null,
20
+ children
21
+ };
22
+ return task;
23
+ }
24
+
25
+ test('duplicateTask resolves by id and reports subtask count', () => {
26
+ const child = makeTask('c1', 'Child');
27
+ const source = makeTask('t1', 'Parent', [child]);
28
+ const clone = makeTask('t1-copy', 'Parent', [makeTask('c1-copy', 'Child')]);
29
+
30
+ const result = runDuplicateScript({
31
+ injectedArgs: { taskId: 't1', includeSubtasks: true },
32
+ flattenedTasks: [source, child],
33
+ inbox: { ending: 'inbox-end' },
34
+ duplicateTasks: (_tasks: any[], _loc: unknown) => [clone],
35
+ deleteObject: () => {}
36
+ });
37
+
38
+ assert.equal(result.success, true);
39
+ assert.equal(result.newTaskId, 't1-copy');
40
+ assert.equal(result.childrenCount, 1);
41
+ });
42
+
43
+ test('duplicateTask renames the copy when newName provided', () => {
44
+ const source = makeTask('t1', 'Original');
45
+ const clone = makeTask('t1-copy', 'Original');
46
+
47
+ const result = runDuplicateScript({
48
+ injectedArgs: { taskId: 't1', newName: 'Renamed Copy' },
49
+ flattenedTasks: [source],
50
+ inbox: { ending: 'inbox-end' },
51
+ duplicateTasks: () => [clone],
52
+ deleteObject: () => {}
53
+ });
54
+
55
+ assert.equal(result.success, true);
56
+ assert.equal(result.name, 'Renamed Copy');
57
+ });
58
+
59
+ test('duplicateTask strips subtasks when includeSubtasks is false', () => {
60
+ const source = makeTask('t1', 'Parent');
61
+ const cloneChildren = [makeTask('cc1', 'CC1'), makeTask('cc2', 'CC2')];
62
+ const clone = makeTask('t1-copy', 'Parent', cloneChildren);
63
+ const deleted: string[] = [];
64
+
65
+ const result = runDuplicateScript({
66
+ injectedArgs: { taskId: 't1', includeSubtasks: false },
67
+ flattenedTasks: [source],
68
+ inbox: { ending: 'inbox-end' },
69
+ duplicateTasks: () => [clone],
70
+ deleteObject: (obj: any) => { deleted.push(obj.id.primaryKey); }
71
+ });
72
+
73
+ assert.equal(result.success, true);
74
+ assert.deepEqual(deleted, ['cc1', 'cc2']);
75
+ });
76
+
77
+ test('duplicateTask rejects ambiguous name matches', () => {
78
+ const a = makeTask('a', 'Dup');
79
+ const b = makeTask('b', 'Dup');
80
+
81
+ const result = runDuplicateScript({
82
+ injectedArgs: { taskName: 'Dup' },
83
+ flattenedTasks: [a, b],
84
+ inbox: { ending: 'inbox-end' },
85
+ duplicateTasks: () => [],
86
+ deleteObject: () => {}
87
+ });
88
+
89
+ assert.equal(result.success, false);
90
+ assert.match(result.error, /Ambiguous task name/);
91
+ });
92
+
93
+ test('duplicateTask errors when task not found', () => {
94
+ const result = runDuplicateScript({
95
+ injectedArgs: { taskId: 'missing' },
96
+ flattenedTasks: [],
97
+ inbox: { ending: 'inbox-end' },
98
+ duplicateTasks: () => [],
99
+ deleteObject: () => {}
100
+ });
101
+
102
+ assert.equal(result.success, false);
103
+ assert.match(result.error, /Task not found/);
104
+ });
@@ -0,0 +1,51 @@
1
+ import { executeOmniFocusScript } from '../../utils/scriptExecution.js';
2
+
3
+ export interface DuplicateTaskParams {
4
+ taskId?: string;
5
+ taskName?: string;
6
+ newName?: string; // Optional name for the duplicated task
7
+ includeSubtasks?: boolean; // Whether to keep subtasks in the copy (default: true)
8
+ }
9
+
10
+ interface DuplicateTaskScriptResult {
11
+ success: boolean;
12
+ newTaskId?: string;
13
+ name?: string;
14
+ childrenCount?: number;
15
+ error?: string;
16
+ }
17
+
18
+ export async function duplicateTask(params: DuplicateTaskParams): Promise<{
19
+ success: boolean,
20
+ newTaskId?: string,
21
+ name?: string,
22
+ childrenCount?: number,
23
+ error?: string
24
+ }> {
25
+ try {
26
+ if (!params.taskId && !params.taskName) {
27
+ return { success: false, error: 'Either taskId or taskName must be provided' };
28
+ }
29
+
30
+ const result = await executeOmniFocusScript('@duplicateTask.js', {
31
+ taskId: params.taskId || null,
32
+ taskName: params.taskName || null,
33
+ newName: params.newName || null,
34
+ includeSubtasks: params.includeSubtasks !== undefined ? params.includeSubtasks : true
35
+ }) as DuplicateTaskScriptResult;
36
+
37
+ if (!result || result.success !== true) {
38
+ return { success: false, error: (result && result.error) || 'Failed to duplicate task' };
39
+ }
40
+
41
+ return {
42
+ success: true,
43
+ newTaskId: result.newTaskId,
44
+ name: result.name,
45
+ childrenCount: result.childrenCount
46
+ };
47
+ } catch (error: any) {
48
+ console.error('Error in duplicateTask:', error);
49
+ return { success: false, error: error?.message || 'Unknown error in duplicateTask' };
50
+ }
51
+ }
@@ -0,0 +1,76 @@
1
+ // Duplicate an OmniFocus task, including its subtasks, using Omni Automation.
2
+ (() => {
3
+ try {
4
+ var args = typeof injectedArgs !== "undefined" ? injectedArgs : {};
5
+ var taskId = args.taskId || null;
6
+ var taskName = args.taskName || null;
7
+ var newName = args.newName || null;
8
+ var includeSubtasks = args.includeSubtasks !== undefined ? args.includeSubtasks : true;
9
+
10
+ if (!taskId && !taskName) {
11
+ return JSON.stringify({ success: false, error: "Either taskId or taskName must be provided" });
12
+ }
13
+
14
+ // Resolve the source task with duplicate-name protection.
15
+ var matches = flattenedTasks.filter(function (candidate) {
16
+ if (taskId) return candidate.id.primaryKey === taskId;
17
+ return candidate.name === taskName;
18
+ });
19
+
20
+ if (matches.length === 0) {
21
+ return JSON.stringify({ success: false, error: "Task not found" });
22
+ }
23
+ if (matches.length > 1 && !taskId) {
24
+ return JSON.stringify({
25
+ success: false,
26
+ error: "Ambiguous task name: " + taskName + ". Multiple matches found; please use taskId."
27
+ });
28
+ }
29
+
30
+ var source = matches[0];
31
+
32
+ // Determine the drop location (same container as the source task).
33
+ var location;
34
+ if (source.parent) {
35
+ location = source.parent.ending;
36
+ } else if (source.containingProject) {
37
+ location = source.containingProject.ending;
38
+ } else {
39
+ location = inbox.ending;
40
+ }
41
+
42
+ // duplicateTasks preserves subtasks natively.
43
+ var duplicated = duplicateTasks([source], location);
44
+ var newTask = duplicated && duplicated.length ? duplicated[0] : null;
45
+
46
+ if (!newTask) {
47
+ return JSON.stringify({ success: false, error: "Duplication failed: no task returned" });
48
+ }
49
+
50
+ // Optionally rename the duplicate.
51
+ if (newName) {
52
+ newTask.name = newName;
53
+ }
54
+
55
+ // Optionally strip subtasks if the caller does not want them.
56
+ if (!includeSubtasks && newTask.children && newTask.children.length > 0) {
57
+ // Iterate over a copy since deletion mutates the collection.
58
+ var children = newTask.children.slice();
59
+ children.forEach(function (child) {
60
+ deleteObject(child);
61
+ });
62
+ }
63
+
64
+ return JSON.stringify({
65
+ success: true,
66
+ newTaskId: newTask.id.primaryKey,
67
+ name: newTask.name,
68
+ childrenCount: newTask.children ? newTask.children.length : 0
69
+ });
70
+ } catch (error) {
71
+ return JSON.stringify({
72
+ success: false,
73
+ error: error && error.message ? error.message : String(error)
74
+ });
75
+ }
76
+ })();