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 +36 -1
- package/dist/server.js +9 -1
- package/dist/tools/definitions/appendToNote.js +41 -0
- package/dist/tools/definitions/countTasks.js +41 -0
- package/dist/tools/definitions/duplicateTask.js +42 -0
- package/dist/tools/primitives/appendToNote.js +122 -0
- package/dist/tools/primitives/appendToNote.test.js +45 -0
- package/dist/tools/primitives/countTasks.js +34 -0
- package/dist/tools/primitives/duplicateTask.js +27 -0
- package/dist/tools/primitives/duplicateTask.test.js +85 -0
- package/dist/utils/omnifocusScripts/duplicateTask.js +76 -0
- package/package.json +1 -1
- package/src/server.ts +27 -1
- package/src/tools/definitions/appendToNote.ts +46 -0
- package/src/tools/definitions/countTasks.ts +45 -0
- package/src/tools/definitions/duplicateTask.ts +47 -0
- package/src/tools/primitives/appendToNote.test.ts +54 -0
- package/src/tools/primitives/appendToNote.ts +150 -0
- package/src/tools/primitives/countTasks.ts +53 -0
- package/src/tools/primitives/duplicateTask.test.ts +104 -0
- package/src/tools/primitives/duplicateTask.ts +51 -0
- package/src/utils/omnifocusScripts/duplicateTask.js +76 -0
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
})();
|